From 5a81d3c2f5a831413e261373d229fedc71f2675d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 17:12:19 +0200 Subject: [PATCH] sync(chunk 2a): vendor dav4jvm 2.2.1 as :dav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .forgejo/workflows/ci.yaml | 5 +- app/build.gradle.kts | 9 + .../agendula/domain/ical/ICalParser.kt | 9 +- .../agendula/domain/ical/ICalParserTest.kt | 2 +- dav/LICENSE | 373 +++++++ dav/PROVENANCE.md | 143 +++ dav/build.gradle.kts | 36 + .../bitfire/dav4jvm/BasicDigestAuthHandler.kt | 294 ++++++ .../at/bitfire/dav4jvm/CallbackInterfaces.kt | 40 + .../main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt | 15 + .../at/bitfire/dav4jvm/DavAddressBook.kt | 141 +++ .../kotlin/at/bitfire/dav4jvm/DavCalendar.kt | 178 ++++ .../at/bitfire/dav4jvm/DavCollection.kt | 123 +++ .../kotlin/at/bitfire/dav4jvm/DavResource.kt | 780 ++++++++++++++ .../main/kotlin/at/bitfire/dav4jvm/Error.kt | 55 + .../kotlin/at/bitfire/dav4jvm/HttpUtils.kt | 140 +++ .../kotlin/at/bitfire/dav4jvm/PropStat.kt | 69 ++ .../kotlin/at/bitfire/dav4jvm/Property.kt | 59 ++ .../at/bitfire/dav4jvm/PropertyFactory.kt | 26 + .../at/bitfire/dav4jvm/PropertyRegistry.kt | 95 ++ .../at/bitfire/dav4jvm/QuotedStringUtils.kt | 36 + .../kotlin/at/bitfire/dav4jvm/Response.kt | 222 ++++ .../kotlin/at/bitfire/dav4jvm/UrlUtils.kt | 109 ++ .../kotlin/at/bitfire/dav4jvm/XmlUtils.kt | 102 ++ .../dav4jvm/exception/ConflictException.kt | 17 + .../bitfire/dav4jvm/exception/DavException.kt | 144 +++ .../dav4jvm/exception/ForbiddenException.kt | 17 + .../dav4jvm/exception/GoneException.kt | 17 + .../dav4jvm/exception/HttpException.kt | 29 + .../dav4jvm/exception/NotFoundException.kt | 17 + .../exception/PreconditionFailedException.kt | 17 + .../exception/ServiceUnavailableException.kt | 42 + .../exception/UnauthorizedException.kt | 17 + .../at/bitfire/dav4jvm/property/AddMember.kt | 32 + .../bitfire/dav4jvm/property/AddressData.kt | 38 + .../property/AddressbookDescription.kt | 33 + .../dav4jvm/property/AddressbookHomeSet.kt | 30 + .../bitfire/dav4jvm/property/CalendarColor.kt | 65 ++ .../bitfire/dav4jvm/property/CalendarData.kt | 38 + .../dav4jvm/property/CalendarDescription.kt | 34 + .../dav4jvm/property/CalendarHomeSet.kt | 30 + .../dav4jvm/property/CalendarProxyReadFor.kt | 30 + .../dav4jvm/property/CalendarProxyWriteFor.kt | 30 + .../dav4jvm/property/CalendarTimezone.kt | 33 + .../property/CalendarUserAddressSet.kt | 30 + .../bitfire/dav4jvm/property/CreationDate.kt | 32 + .../dav4jvm/property/CurrentUserPrincipal.kt | 42 + .../property/CurrentUserPrivilegeSet.kt | 88 ++ .../bitfire/dav4jvm/property/DisplayName.kt | 33 + .../at/bitfire/dav4jvm/property/GetCTag.kt | 32 + .../dav4jvm/property/GetContentLength.kt | 38 + .../dav4jvm/property/GetContentType.kt | 36 + .../at/bitfire/dav4jvm/property/GetETag.kt | 78 ++ .../dav4jvm/property/GetLastModified.kt | 40 + .../dav4jvm/property/GroupMembership.kt | 30 + .../dav4jvm/property/HrefListProperty.kt | 36 + .../dav4jvm/property/MaxICalendarSize.kt | 38 + .../bitfire/dav4jvm/property/MaxVCardSize.kt | 38 + .../at/bitfire/dav4jvm/property/Owner.kt | 30 + .../dav4jvm/property/QuotaAvailableBytes.kt | 38 + .../dav4jvm/property/QuotaUsedBytes.kt | 38 + .../bitfire/dav4jvm/property/ResourceType.kt | 63 ++ .../bitfire/dav4jvm/property/ScheduleTag.kt | 45 + .../at/bitfire/dav4jvm/property/Source.kt | 30 + .../dav4jvm/property/SupportedAddressData.kt | 65 ++ .../property/SupportedCalendarComponentSet.kt | 67 ++ .../dav4jvm/property/SupportedCalendarData.kt | 64 ++ .../dav4jvm/property/SupportedReportSet.kt | 58 ++ .../at/bitfire/dav4jvm/property/SyncToken.kt | 33 + .../dav4jvm/BasicDigestAuthHandlerTest.kt | 282 +++++ .../at/bitfire/dav4jvm/DavCollectionTest.kt | 257 +++++ .../at/bitfire/dav4jvm/DavResourceTest.kt | 971 ++++++++++++++++++ .../kotlin/at/bitfire/dav4jvm/ErrorTest.kt | 20 + .../at/bitfire/dav4jvm/HttpUtilsTest.kt | 25 + .../at/bitfire/dav4jvm/LocalChangesTest.kt | 199 ++++ .../bitfire/dav4jvm/QuotedStringUtilsTest.kt | 34 + .../kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt | 58 ++ .../kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt | 97 ++ .../dav4jvm/exception/DavExceptionTest.kt | 176 ++++ .../dav4jvm/exception/HttpExceptionTest.kt | 43 + .../ServiceUnavailableExceptionTest.kt | 65 ++ .../property/CalendarDescriptionTest.kt | 15 + .../bitfire/dav4jvm/property/GetETagTest.kt | 48 + .../at/bitfire/dav4jvm/property/OwnerTest.kt | 30 + .../bitfire/dav4jvm/property/PropertyTest.kt | 20 + docs/SYNC-PLAN.md | 48 +- gradle/libs.versions.toml | 15 + settings.gradle.kts | 1 + 88 files changed, 7288 insertions(+), 9 deletions(-) create mode 100644 dav/LICENSE create mode 100644 dav/PROVENANCE.md create mode 100644 dav/build.gradle.kts create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt create mode 100644 dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt create mode 100644 dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index af678bc..a337e70 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -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' diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1639fed..f7e37aa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt index ba09c20..08f2520 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt @@ -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 { val lines = mutableListOf() - 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' diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt index a95f744..9a39c59 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt @@ -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") } } diff --git a/dav/LICENSE b/dav/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/dav/LICENSE @@ -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. diff --git a/dav/PROVENANCE.md b/dav/PROVENANCE.md new file mode 100644 index 0000000..8e3f514 --- /dev/null +++ b/dav/PROVENANCE.md @@ -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 1–4, run +`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the +entire reason it is vendored alongside the code. diff --git a/dav/build.gradle.kts b/dav/build.gradle.kts new file mode 100644 index 0000000..039e247 --- /dev/null +++ b/dav/build.gradle.kts @@ -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) +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt new file mode 100644 index 0000000..54a8399 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt @@ -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() + 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) + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt new file mode 100644 index 0000000..b31b9a1 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt @@ -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, response: okhttp3.Response) +} + +/** + * Callback for 207 Multi-Status responses. + */ +fun interface MultiResponseCallback { + /** + * Called for every `` element in the `` 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) +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt new file mode 100644 index 0000000..f9dfb78 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt @@ -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")!! + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt new file mode 100644 index 0000000..1b4130e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt @@ -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 { + /* + + */ + 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, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + /* + */ + 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) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt new file mode 100644 index 0000000..df6867b --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt @@ -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 { + /* + + + + 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, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + /* + */ + 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) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt new file mode 100644 index 0000000..5dfcfab --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt @@ -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 { + /* + + + + + + + + + */ + 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) + } + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt new file mode 100644 index 0000000..4c09bdf --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt @@ -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 = ", + removeProperties: List + ): 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 */* 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 */* 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 */* 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, + removeProperties: List, + 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 { + 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 { + val responseProperties = mutableListOf() + val parser = XmlUtils.newPullParser() + + fun parseMultiStatus(): List { + // + 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 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) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt new file mode 100644 index 0000000..c98e41a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt @@ -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 { + val names = mutableSetOf() + + 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() + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt new file mode 100644 index 0000000..f45167a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt @@ -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 { + 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 + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt new file mode 100644 index 0000000..010d7dd --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt @@ -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. + * + * + */ +data class PropStat( + val properties: List, + val status: StatusLine, + val error: List? = 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() + + 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 + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt new file mode 100644 index 0000000..6a7209a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt @@ -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 { + // + val depth = parser.depth + val properties = LinkedList() + + 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 + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt new file mode 100644 index 0000000..699e6f4 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt @@ -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? + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt new file mode 100644 index 0000000..4b0a374 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt @@ -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() + + 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) { + 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 + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt new file mode 100644 index 0000000..e3a516e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt @@ -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 = > + 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 + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt new file mode 100644 index 0000000..6dd4e1f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt @@ -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. + * + * + */ +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, + + /** + * list of precondition/postcondition elements (`error` XML elements) + */ + val error: List? = 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 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 get(clazz: Class) = + 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() + var error: List? = 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 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) + } + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt new file mode 100644 index 0000000..f6feed7 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt @@ -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() + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt new file mode 100644 index 0000000..d128e6a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt @@ -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) { + 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) + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt new file mode 100644 index 0000000..2477a77 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt new file mode 100644 index 0000000..60d129f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt @@ -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 = 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 + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt new file mode 100644 index 0000000..23580a8 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt new file mode 100644 index 0000000..01460f5 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt new file mode 100644 index 0000000..97422fa --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt @@ -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 + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt new file mode 100644 index 0000000..1277530 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt new file mode 100644 index 0000000..9e980e6 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt new file mode 100644 index 0000000..af2cd62 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt @@ -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 + } + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt new file mode 100644 index 0000000..cd3d0eb --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt @@ -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) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt new file mode 100644 index 0000000..4fd5e80 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt @@ -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)) + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt new file mode 100644 index 0000000..e8cc5a2 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt @@ -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) = + // + AddressData(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt new file mode 100644 index 0000000..6d892af --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt @@ -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) = + // + AddressbookDescription(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt new file mode 100644 index 0000000..3f89342 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt new file mode 100644 index 0000000..38ffc74 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt @@ -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 + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt new file mode 100644 index 0000000..59fdecb --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt @@ -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) = + // + CalendarData(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt new file mode 100644 index 0000000..c8b9e5f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt @@ -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) = + // + CalendarDescription(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt new file mode 100644 index 0000000..4ca776e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt new file mode 100644 index 0000000..2a74dee --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt new file mode 100644 index 0000000..dcd3834 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt new file mode 100644 index 0000000..c86d524 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt @@ -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) = + // + CalendarTimezone(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt new file mode 100644 index 0000000..5c2c128 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt new file mode 100644 index 0000000..e64da0c --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt new file mode 100644 index 0000000..460831b --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt @@ -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 { + // + var href: String? = null + XmlUtils.processTag(parser, DavResource.HREF) { + href = XmlUtils.readText(parser) + } + return CurrentUserPrincipal(href) + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt new file mode 100644 index 0000000..53a5431 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt @@ -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? { + // + // + 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 + } + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt new file mode 100644 index 0000000..1d2ed4a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt @@ -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) = + // + DisplayName(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt new file mode 100644 index 0000000..941d90f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt @@ -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)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt new file mode 100644 index 0000000..f10a4b8 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt new file mode 100644 index 0000000..9ab0a22 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt @@ -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) = + // + GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt new file mode 100644 index 0000000..7baf870 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt @@ -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)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt new file mode 100644 index 0000000..2028e3d --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt @@ -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? { + // + 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 + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt new file mode 100644 index 0000000..e876524 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt @@ -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()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt new file mode 100644 index 0000000..6a7e154 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt @@ -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() + + 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 + } + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt new file mode 100644 index 0000000..6fc6b22 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt new file mode 100644 index 0000000..4a09501 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt new file mode 100644 index 0000000..bd48b82 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt @@ -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()) + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt new file mode 100644 index 0000000..7423029 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt new file mode 100644 index 0000000..b716389 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt new file mode 100644 index 0000000..4b8523f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt @@ -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() + + 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 + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt new file mode 100644 index 0000000..62e5d16 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt @@ -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)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt new file mode 100644 index 0000000..8691df4 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt @@ -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()) + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt new file mode 100644 index 0000000..63c0b0d --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt @@ -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() + + 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 ", e) + return null + } + + return supported + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt new file mode 100644 index 0000000..c58e5ae --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt @@ -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? { + /* + + + */ + 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 + } + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt new file mode 100644 index 0000000..ca11581 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt @@ -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() + + 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 ", e) + return null + } + + return supported + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt new file mode 100644 index 0000000..f1bde0a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt @@ -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() + + override fun toString() = "[${reports.joinToString(", ")}]" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): SupportedReportSet? { + /* + + + */ + + 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 + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt new file mode 100644 index 0000000..9310529 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt @@ -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) = + // + SyncToken(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt new file mode 100644 index 0000000..a33a284 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt @@ -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( + 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( + 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( // 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( + 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( + 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( + 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()))) + + assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + Pair("realm", "Group-Office") + )))) + + assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + Pair("realm", "Group-Office"), + Pair("qop", "auth") + )))) + + assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + 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) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt new file mode 100644 index 0000000..2ca5cdd --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt @@ -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("\n" + + " \n" + + " \n" + + " ${sampleUrl()}test.doc\n" + + " \n" + + " \n" + + " \"00001-abcd1\"\n" + + " \n" + + " Box type A\n" + + " \n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}vcard.vcf\n" + + " \n" + + " \n" + + " \"00002-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " HTTP/1.1 404 Not Found\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}calendar.ics\n" + + " \n" + + " \n" + + " \"00003-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " HTTP/1.1 404 Not Found\n" + + " \n" + + " \n" + + " http://example.com/ns/sync/1234\n" + + " ") + ) + 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("\n" + + " \n" + + " \n" + + " ${sampleUrl()}test.doc\n" + + " \n" + + " \n" + + " \"00001-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}vcard.vcf\n" + + " \n" + + " \n" + + " \"00002-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}removed.txt\n" + + " HTTP/1.1 404 Not Found\n" + + " " + + " \n" + + " ${sampleUrl()}\n" + + " HTTP/1.1 507 Insufficient Storage\n" + + " \n" + + " " + + " http://example.com/ns/sync/1233\n" + + " ") + ) + 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("\n" + + " \n" + + " \n" + + " ") + ) + + 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) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt new file mode 100644 index 0000000..be6c1bd --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt @@ -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("")) + 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("")) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // * response without root element + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("")) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // * multi-status response with invalid in + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " Invalid Status Line" + + " " + + "")) + 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 / element indicating failure + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " HTTP/1.1 403 Forbidden" + + " " + + "")) + 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 in + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " " + + " " + + " Invalid Status Line" + + " " + + " " + + "")) + 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 elements + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("")) + dav.propfind(0, ResourceType.NAME) { _, _ -> + fail("Shouldn't be called") + } + + // multi-status response with / element indicating success + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " HTTP/1.1 200 OK" + + " " + + "")) + 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 / element + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "")) + 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 s listed) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " " + url.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/subcollection" + + " " + + " " + + " " + + " A Subfolder" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/uid@host:file" + + " " + + " " + + " Absolute path with @ and :" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative-uid@host.file" + + " " + + " " + + " Relative path with @" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative:colon.vcf" + + " " + + " " + + " Relative path with colon" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /something-very/else" + + " " + + " " + + " Not requested" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "")) + 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 (seen in iCloud) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " " + url.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "")) + 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 that doesn't contain (=> assume 200 OK) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " Without Status" + + " " + + " " + + " " + + "")) + 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 / elements + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " Some Value" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "")) + + 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("" + + "" + + "Some Value" + + "" + + "", 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("" + + " " + + " /dav" + + " " + + " " + + " Found something" + + " " + + " " + + " " + + "")) + var called = false + dav.search("") { 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("", 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("".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()) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt new file mode 100644 index 0000000..8fc47c6 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt @@ -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)) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt new file mode 100644 index 0000000..b7c8efa --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt @@ -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())) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt new file mode 100644 index 0000000..35992ed --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt @@ -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 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) + } + } +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt new file mode 100644 index 0000000..7e7aa6c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt @@ -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\\\"\"")) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt new file mode 100644 index 0000000..466552c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt @@ -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()!!)) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt new file mode 100644 index 0000000..b29488f --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt @@ -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("")) + // 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("")) + parser.next() // now on START_TAG + + var processed = false + XmlUtils.processTag(parser, Property.Name("", "test")) { + processed = true + } + assertTrue(processed) + } + + @Test + fun testReadText() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() + parser.next() // now on START_TAG + + 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 2]]>")) + parser.next() // now on START_TAG + + assertEquals("Test 1Test 2", XmlUtils.readText(parser)) + assertEquals(XmlPullParser.END_TAG, parser.eventType) + } + + @Test + fun testReadTextPropertyRoot() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() // now on START_TAG + + val entries = mutableListOf() + XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries) + assertEquals("Test 1", entries[0]) + assertEquals("Test 2", entries[1]) + + parser.next() // END_TAG + assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType) + } + + @Test + fun testReadTextPropertyListDepth1() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() // now on START_TAG [1] + + val entries = mutableListOf() + 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) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt new file mode 100644 index 0000000..071c953 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt @@ -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 = "\n" + + "\n" + + " \n" + + " /workspace/webdav/\n" + + " \n" + + "\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) + } + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt new file mode 100644 index 0000000..f66a4cf --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt @@ -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")) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt new file mode 100644 index 0000000..5294641 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt @@ -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 + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt new file mode 100644 index 0000000..c5ebd19 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt @@ -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("My Calendar") + val result = results.first() as CalendarDescription + assertEquals("My Calendar", result.description) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt new file mode 100644 index 0000000..0dd971c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt @@ -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("") + val getETag = results.first() as GetETag + assertNull(getETag.eTag) + assertNull(getETag.weak) + } + + @Test + fun testGetETag_Strong() { + val results = parseProperty("\"Correct strong ETag\"") + val getETag = results.first() as GetETag + assertEquals("Correct strong ETag", getETag.eTag) + assertFalse(getETag.weak!!) + } + + @Test + fun testGetETag_Strong_NoQuotes() { + val results = parseProperty("Strong ETag without quotes") + val getETag = results.first() as GetETag + assertEquals("Strong ETag without quotes", getETag.eTag) + assertFalse(getETag.weak!!) + } + + @Test + fun testGetETag_Weak() { + val results = parseProperty("W/\"Correct weak ETag\"") + val getETag = results.first() as GetETag + assertEquals("Correct weak ETag", getETag.eTag) + assertTrue(getETag.weak!!) + } + + @Test + fun testGetETag_Weak_NoQuotes() { + val results = parseProperty("W/Weak ETag without quotes") + val getETag = results.first() as GetETag + assertEquals("Weak ETag without quotes", getETag.eTag) + assertTrue(getETag.weak!!) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt new file mode 100644 index 0000000..39584f8 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt @@ -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("https://example.com") + val owner = results.first() as Owner + assertNull(owner.href) + } + + @Test + fun testOwner_PlainTextAndHref() { + val results = parseProperty("Principal Name. mailto:owner@example.com (test)") + val owner = results.first() as Owner + assertEquals("mailto:owner@example.com", owner.href) + } + + @Test + fun testOwner_Href() { + val results = parseProperty("https://example.com") + val owner = results.first() as Owner + assertEquals("https://example.com", owner.href) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt new file mode 100644 index 0000000..29d88de --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt @@ -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 { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("$s")) + parser.nextTag() // move into + return Property.parse(parser) + } + + } + +} \ No newline at end of file diff --git a/docs/SYNC-PLAN.md b/docs/SYNC-PLAN.md index 0530391..725ffe6 100644 --- a/docs/SYNC-PLAN.md +++ b/docs/SYNC-PLAN.md @@ -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** | 1–4 | 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 2a — the 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c57fbae..291f87e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f522deb..c9c782f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ dependencyResolutionManagement { rootProject.name = "Agendula" include(":app") +include(":dav") includeBuild("floret-kit")