Release 1.0.0 (#20)

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

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

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

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

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

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
Jean-Luc Makiola
2026-09-24 16:36:49 +02:00
co-authored by makiolaj
parent 7bbdbe60e3
commit ac01993d41
476 changed files with 53728 additions and 2919 deletions
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+311
View File
@@ -0,0 +1,311 @@
# `: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
We *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, 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: this layer is earmarked
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; persisting the new
URL ourselves is 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.
### 6. `<D:unauthenticated/>` is parsed instead of inferred
`CurrentUserPrincipal.Factory` read only the `<href>` child, so an
unauthenticated body (RFC 5397 §3 — a **200** whose content means the request was
not authenticated) arrived as "property present, href null" — identical to a
conformant-but-empty element, and to a server that omits the property entirely.
A caller inferring rejection from the null href therefore also fires on merely
non-conformant servers, and on a request that carried no credential at all.
The factory now makes one pass over both children (`XmlUtils.processTag` consumes
to the end tag and so cannot be called twice) and reports `unauthenticated`
explicitly. Without it, a rejected credential is indistinguishable from a
successful discovery that found nothing.
### 7. WebDAV-Push properties, backported
2.2.1 predates the WebDAV-Push draft (`https://bitfire.at/webdav-push`).
`property/push/` adds what a client reads: the element names, `P:transports`
(Web Push only, with the server's VAPID key) and `P:topic`, both registered in
`PropertyRegistry`. Modelled on upstream's `property/push`, but cut down: the
request bodies and push messages are built and parsed in `:caldav`, which is the
only caller, so upstream's register/message/trigger property classes are left
out.
### 8. HTTP dates are formatted in `Locale.US`
Change 5 built the formatter with `Locale.ROOT`. A desktop JVM formats that in
English, so every test passed; Android's ICU root locale abbreviates months as
`M01`–`M12`, so on a device `formatDate` produced `Thu, 01 M10 2026 …`. Nothing
used it until WebDAV-Push sent it as `<expires>`, and Nextcloud's `dav_push`
app answered every registration with a 500. `parseDate` already used
`Locale.US`; formatting now matches 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
Two dav4jvm defects were expected to carry over. 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.
## Change 7 — a same-host HTTPS→HTTP redirect is upgraded, not refused
`DavResource.followRedirects` threw `DavException("Received redirect from HTTPS
to HTTP")` for any downgrade. That is right for a redirect to a *different* host,
which has no innocent reading. It is wrong for the same host, and the same host
is the case that actually occurs.
⚠️ **A Nextcloud behind a TLS-terminating reverse proxy without
`overwriteprotocol` — or without `proxy_set_header X-Forwarded-Proto $scheme` —
builds every redirect with `http://`.** That includes the `/.well-known/caldav`
hop RFC 6764 discovery depends on. The server is entirely functional:
`/remote.php/dav/` answers 401 over HTTPS exactly as it should. But discovery
refuses the downgrade, falls back to a `PROPFIND` on the web root, gets the 405
an ordinary web server returns, and reports "not a CalDAV server" about a working
CalDAV server.
Now: when the redirect target's host matches the current one, the scheme is put
back to `https` and the hop continues. Re-issuing the same host and path over TLS
is *strictly safer* than obeying the redirect as sent, and it preserves the
invariant that matters — credentials never travel in cleartext. A cross-host
downgrade still throws.
Found against a real server, not by reading: `cloud.jeanlucmakiola.de` returns
`301 → http://cloud.jeanlucmakiola.de/remote.php/dav/`.
## Change 8 — the credential is scoped by the public-suffix list, not by a label split
`BasicDigestAuthHandler` gated every request on
`domain.equals(UrlUtils.hostToDomain(request host))`, and `hostToDomain` is a
pure last-two-labels split with no public-suffix knowledge. So a server at
`cloud.example.co.uk` scoped the credential to `co.uk`, one at
`myhome.duckdns.org` to `duckdns.org`, and a self-hoster at `192.168.1.10` to
`1.10`.
⚠️ **The handler adds `Authorization: Basic` preemptively**, before any
challenge, to the first HTTPS request to any host that passes that gate. So the
scope is not merely recorded — it is the set of hosts that receive the app
password unprompted.
That is reachable. `ServiceDiscovery` builds candidate origins from SRV targets
without requiring the target to lie inside the queried domain, over plain UDP
DNS with no DNSSEC. Correct scoping forces an on-path attacker to obtain a
certificate for a name inside the victim's own registrable domain, which is
infeasible; `co.uk` scoping lets them point the SRV at a domain they own and
hold a legitimate certificate for.
Now: `request.url.topPrivateDomain() ?: request.url.host`. OkHttp bundles the
public-suffix list including its private section, so the dynamic-DNS providers
self-hosters actually use are covered. `topPrivateDomain()` is null for an IP
literal, a single-label host, and a host that *is* a public suffix — and null
means *no restriction* to this handler, so it falls back to the exact host.
The caller must derive the scope the same way, which is why
`CalDavHttp.authenticated` changed with it: a mismatch withholds the credential
from every request rather than leaking it.
`UrlUtils.hostToDomain` and its test are left alone — after this it has no
production callers, and keeping it keeps the resync diff small.
## Change 9 — Basic is refused over cleartext even when the server asks for it
`insecurePreemptive` gated only the preemptive branch. A plain-HTTP server
answering 401 with a `Basic` challenge still got
`Authorization: Basic <user:password>` in the clear — the flag's name was
accurate and its coverage was not.
The gate now sits on the Basic *emission*, so both paths are covered by one
condition, and the flag is renamed `insecureBasic` to say what it actually
permits. Digest is deliberately untouched: it never puts the password on the
wire, and refusing it would break a LAN server the day the documented
per-account cleartext opt-in ships.
Not currently reachable in the app — `network_security_config.xml` sets
`cleartextTrafficPermitted="false"`, so OkHttp throws before the request is
written, and nothing passes `allowCleartext = true`. Fixed anyway: `:caldav` is
a plain JVM module earmarked for reuse, where the Android policy does not apply,
and the mitigation would evaporate silently the day that opt-in is wired up.
A refused challenge is also not cached. Recording one we never answer leaves the
handler believing Basic is in play, so the preemptive block is skipped, the
refusal repeats, and the 401 after that reports "Basic credentials didn't work
last time" about a credential that never reached the wire.
⚠️ **Upstream's `BasicDigestAuthHandlerTest.testBasic` was amended** — it
asserted exactly this behaviour, using `http://example.com` with a Basic
challenge and expecting the header. Its URL is now `https://`, and the
cleartext cases it used to cover are pinned explicitly by
`cleartextBasicIsRefusedEvenWhenChallenged`,
`cleartextBasicIsSentWhenExplicitlyAllowed` and `cleartextDigestIsStillAnswered`.
This is the one upstream test this port deliberately changes rather than
inherits.
## Change 10 — a Basic challenge no longer hides the Digest one beside it
The 401 branch scanned `response.challenges()` in a loop and did a non-local
`return null` the moment it saw a `Basic` challenge it had already tried. Because
the handler is installed as a **network interceptor**, `basicAuth` is always
primed preemptively over HTTPS — so that fired on the *first* 401, and a
`Digest` challenge later in the same header was never read at all.
A Baïkal or Apache front end that advertises both and rejects Basic at the
application layer therefore never got a Digest answer, and `SyncEngine` marked
the account as needing sign-in permanently.
The loop now reads every challenge before anything is decided; a scheme already
known not to work is simply not re-offered. Giving up is still the outcome when
nothing usable is left — it happens after the whole header has been looked at
rather than in the middle of it.
## Change 11 — the digest counter is per handler, and per nonce
`clientNonce` and `nonceCount` lived on the companion object while `SyncEngine`
builds **one handler per account**, so two accounts syncing at once interleaved
their `nc` values against each other's nonces. They are instance state now.
The count was also never reset. RFC 7616 §3.4.1 defines `nc` as the count of
requests sent *with that nonce*, starting at 1, so a server that enforces it
(Apache `AuthDigestNcCheck On`, several NAS stacks) answers 401 for a rotated
nonce that arrives with a carried-over count. A new server nonce now restarts
it. The client nonce stays put: it is ours, one per handler, and pairing it with
a restarted count is what the RFC describes.
The tell upstream left behind is that every digest test has to reset the counter
by hand — those four assignments now address the handler rather than the class,
which is the only change to that file.
## Change 12 — `qop` list values are trimmed
`paramValue.split(",")` with no `trim()`. HTTP list syntax allows space around
the separator, so `qop="auth, auth-int"` silently downgraded to `auth`, and a
single spaced value (`qop=" auth"`) matched nothing at all — dropping into the
RFC 2069 legacy branch, which emits a response with no `qop`, `nc` or `cnonce`.
An RFC 7616 server rejects that outright: a permanent 401 against a server
behaving perfectly legally. Values are trimmed and compared case-insensitively.
+37
View File
@@ -0,0 +1,37 @@
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 — this layer is earmarked 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)
testImplementation(libs.okhttp.tls)
}
@@ -0,0 +1,361 @@
/*
* 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 sharing this registrable domain, as
* [HttpUrl.topPrivateDomain] derives it — or, for a host that has none
* (an IP literal, `localhost`, a host that *is* a public suffix), only
* against that exact host. Null means no restriction.
*/
val domain: String?,
val username: String,
val password: String,
/**
* Allow Basic over cleartext — both preemptively and in answer to a
* challenge. Off by default: the header is the password.
*/
val insecureBasic: Boolean = false
): Authenticator, Interceptor {
companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
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
/*
* ⚠️ Per handler, not per process. Upstream keeps these on the companion
* object while `SyncEngine` builds one handler per account, so two accounts
* syncing at once interleave their `nc` values against each other's nonces.
* RFC 7616 §3.4.1 defines `nc` as the count of requests sent *with that
* nonce*, starting at 1 — a server that enforces it (Apache
* `AuthDigestNcCheck On`, several NAS stacks) answers 401 and the account is
* marked as needing sign-in. The tell upstream left behind is that every
* digest test has to reset the counter by hand.
*/
var clientNonce = h(UUID.randomUUID().toString())
val nonceCount = AtomicInteger(1)
/** The server nonce [nonceCount] is counting against. */
private var countedNonce: String? = null
fun authenticateRequest(request: Request, response: Response?): Request? {
domain?.let {
val host = request.url.host
// ⚠️ The public-suffix list, not a last-two-labels split. Splitting
// scopes `cloud.example.co.uk` to `co.uk` and `192.168.1.10` to
// `1.10`, handing the credential preemptively to any host that
// matches — including one an attacker can buy a certificate for.
// `topPrivateDomain()` is null for hosts with no registrable domain,
// and null here would mean *no* restriction, so it falls back to the
// exact host.
if (!domain.equals(request.url.topPrivateDomain() ?: 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 || insecureBasic)) {
Dav4jvm.log.fine("Trying Basic auth preemptively")
basicAuth = Challenge("Basic", "")
}
} else {
// we're processing a 401 response
// ⚠️ Every challenge is read before anything is decided. Upstream
// returned out of this loop the moment Basic was found to have
// failed — and because the handler is a network interceptor,
// `basicAuth` is *always* primed preemptively, so that fired on the
// very first 401. A server advertising `Basic` then `Digest` in one
// header therefore never had its Digest challenge read at all: a
// Baikal/Apache front end that offers both and rejects Basic at the
// app layer got no Digest answer, and the account was marked as
// needing sign-in permanently. Giving up is still the outcome when
// nothing usable is left — it just happens after the whole header
// has been looked at.
var newBasicAuth: Challenge? = null
var newDigestAuth: Challenge? = null
for (challenge in response.challenges())
when {
"Basic".equals(challenge.scheme, true) ->
if (basicAuth != null) {
Dav4jvm.log.warning("Basic credentials didn't work last time -> not offering them again")
} else {
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 -> not offering them again")
} else {
newDigestAuth = challenge
}
}
// ⚠️ Not cached if we would refuse to answer it. Caching a challenge
// we never answer leaves the handler believing Basic is in play: the
// preemptive block is skipped, the refusal repeats, and the 401 after
// that reports "Basic credentials didn't work last time" about a
// credential that was never put on the wire.
basicAuth = newBasicAuth?.takeIf { request.isHttps || insecureBasic }
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 -> {
// ⚠️ Gated here, not only on the preemptive path above. Basic
// *is* the password, in a header any hop can read, so a plain
// HTTP server answering 401 with a Basic challenge must not be
// able to ask for it. Digest is left alone: it never transmits
// the password, and refusing it would kill a LAN server the day
// the cleartext opt-in ships.
if (!request.isHttps && !insecureBasic) {
Dav4jvm.log.warning("Refusing to send Basic credentials over cleartext to ${request.url}")
return null
}
Dav4jvm.log.fine("Adding Basic authorization header for ${request.url}")
/* In RFC 2617 (obsolete), there was no encoding for credentials defined, although
one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617,
which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8.
So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many,
but not all pre-RFC 7617 servers. */
return request.newBuilder()
.header(HEADER_AUTHORIZATION, Credentials.basic(username, password, Charsets.UTF_8))
.build()
}
response != null ->
Dav4jvm.log.warning("No supported authentication scheme")
}
return null
}
fun digestRequest(request: Request, digest: Challenge?): Request? {
if (digest == null)
return null
val realm = digest.authParams["realm"]
val opaque = digest.authParams["opaque"]
val nonce = digest.authParams["nonce"]
val algorithm = Algorithm.determine(digest.authParams["algorithm"])
val qop = Protection.selectFrom(digest.authParams["qop"])
// build response parameters
var response: String? = null
val params = LinkedList<String>()
params.add("username=${quotedString(username)}")
if (realm != null)
params.add("realm=${quotedString(realm)}")
else {
Dav4jvm.log.warning("No realm provided, aborting Digest auth")
return null
}
if (nonce != null)
params.add("nonce=${quotedString(nonce)}")
else {
Dav4jvm.log.warning("No nonce provided, aborting Digest auth")
return null
}
if (opaque != null)
params.add("opaque=${quotedString(opaque)}")
if (algorithm != null)
params.add("algorithm=${quotedString(algorithm.algorithm)}")
val method = request.method
val digestURI = request.url.encodedPath
params.add("uri=${quotedString(digestURI)}")
if (qop != null) {
// ⚠️ A new server nonce restarts the count at 1: §3.4.1 counts
// requests sent with *that* nonce, so carrying the count over makes
// the first request against a rotated nonce look like the
// thousandth. The client nonce stays put — it is ours, one per
// handler, and pairing it with a restarted count is what the RFC
// describes.
if (nonce != countedNonce) {
countedNonce = nonce
nonceCount.set(1)
}
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
// ⚠️ Trimmed. HTTP list syntax allows space around the
// separator, so `qop="auth, auth-int"` silently downgraded
// to auth and a single spaced value matched nothing at all —
// which dropped into the RFC 2069 branch and emitted a
// response with no qop, nc or cnonce, for a permanent 401
// against a server behaving legally.
for (qop in paramValue.split(","))
when (qop.trim().lowercase()) {
"auth" -> qopAuth = true
"auth-int" -> qopAuthInt = true
}
// prefer auth-int as it provides more protection
if (qopAuthInt)
return AuthInt
else if (qopAuth)
return Auth
}
return null
}
}
}
override fun authenticate(route: Route?, response: Response) =
authenticateRequest(response.request, response)
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (request.header(HEADER_AUTHORIZATION) == null) {
// try to apply cached authentication
val authRequest = authenticateRequest(request, null)
if (authRequest != null)
request = authRequest
}
return chain.proceed(request)
}
}
@@ -0,0 +1,40 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
/**
* Callback for the OPTIONS request.
*/
fun interface CapabilitiesCallback {
fun onCapabilities(davCapabilities: Set<String>, response: okhttp3.Response)
}
/**
* Callback for 207 Multi-Status responses.
*/
fun interface MultiResponseCallback {
/**
* Called for every `<response>` element in the `<multistatus>` body. For instance,
* in response to a `PROPFIND` request, this callback will be called once for every found
* member resource.
*
* @param response the parsed response (including URL)
* @param relation relation of the response to the called resource
*/
fun onResponse(response: Response, relation: Response.HrefRelation)
}
/**
* Callback for HTTP responses.
*/
fun interface ResponseCallback {
/**
* Called for a HTTP response. Typically this is only called for successful/redirect
* responses because HTTP errors throw an exception before this callback is called.
*/
fun onResponse(response: okhttp3.Response)
}
@@ -0,0 +1,15 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import java.util.logging.Logger
object Dav4jvm {
var log = Logger.getLogger("dav4jvm")!!
}
@@ -0,0 +1,141 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.AddressData
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Logger
class DavAddressBook @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavCollection(httpClient, location, log) {
companion object {
val MIME_JCARD = "application/vcard+json".toMediaType()
val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType()
val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType()
val ADDRESSBOOK_QUERY = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-query")
val ADDRESSBOOK_MULTIGET = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-multiget")
val FILTER = Property.Name(XmlUtils.NS_CARDDAV, "filter")
}
/**
* Sends an addressbook-query REPORT request to the resource.
*
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun addressbookQuery(callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT addressbook-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, limit?)>
<!ELEMENT filter (prop-filter*)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.insertTag(ADDRESSBOOK_QUERY) {
insertTag(PROP) {
insertTag(GetETag.NAME)
}
insertTag(FILTER)
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "1")
.build()).execute()
}.use { response ->
return processMultiStatus(response, callback)
}
}
/**
* Sends an addressbook-multiget REPORT request to the resource.
*
* @param urls list of vCard URLs to be requested
* @param contentType MIME type of requested format; may be "text/vcard" for vCard or
* "application/vcard+json" for jCard. *null*: don't request specific representation type
* @param version vCard version subtype of the requested format. Should only be specified together with a [contentType] of "text/vcard".
* Currently only useful value: "4.0" for vCard 4. *null*: don't request specific version
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT addressbook-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?,
DAV:href+)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.insertTag(ADDRESSBOOK_MULTIGET) {
insertTag(PROP) {
insertTag(GetContentType.NAME)
insertTag(GetETag.NAME)
insertTag(AddressData.NAME) {
if (contentType != null)
attribute(null, AddressData.CONTENT_TYPE, contentType)
if (version != null)
attribute(null, AddressData.VERSION, version)
}
}
for (url in urls)
insertTag(HREF) {
text(url.encodedPath)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,178 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CalendarData
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.ScheduleTag
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Logger
class DavCalendar @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavCollection(httpClient, location, log) {
companion object {
val MIME_ICALENDAR = "text/calendar".toMediaType()
val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType()
val CALENDAR_QUERY = Property.Name(XmlUtils.NS_CALDAV, "calendar-query")
val CALENDAR_MULTIGET = Property.Name(XmlUtils.NS_CALDAV, "calendar-multiget")
val FILTER = Property.Name(XmlUtils.NS_CALDAV, "filter")
val COMP_FILTER = Property.Name(XmlUtils.NS_CALDAV, "comp-filter")
const val COMP_FILTER_NAME = "name"
val TIME_RANGE = Property.Name(XmlUtils.NS_CALDAV, "time-range")
const val TIME_RANGE_START = "start"
const val TIME_RANGE_END = "end"
private val timeFormatUTC = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
init {
timeFormatUTC.timeZone = TimeZone.getTimeZone("Etc/UTC")
}
}
/**
* Sends a calendar-query REPORT to the resource.
*
* @param component requested component name (like VEVENT or VTODO)
* @param start time-range filter: start date (optional)
* @param end time-range filter: end date (optional)
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun calendarQuery(component: String, start: Date?, end: Date?, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT calendar-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, timezone?)>
<!ELEMENT filter (comp-filter)>
<!ELEMENT comp-filter (is-not-defined | (time-range?,
prop-filter*, comp-filter*))>
<!ATTLIST comp-filter name CDATA #REQUIRED>
name value: a calendar object or calendar component
type (e.g., VEVENT)
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.insertTag(CALENDAR_QUERY) {
insertTag(PROP) {
insertTag(GetETag.NAME)
}
insertTag(FILTER) {
insertTag(COMP_FILTER) {
attribute(null, COMP_FILTER_NAME, "VCALENDAR")
insertTag(COMP_FILTER) {
attribute(null, COMP_FILTER_NAME, component)
if (start != null || end != null) {
insertTag(TIME_RANGE) {
if (start != null)
attribute(null, TIME_RANGE_START, timeFormatUTC.format(start))
if (end != null)
attribute(null, TIME_RANGE_END, timeFormatUTC.format(end))
}
}
}
}
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "1")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
/**
* Sends a calendar-multiget REPORT to the resource. Received responses are sent
* to the callback, whether they are successful (2xx) or not.
*
* @param urls list of iCalendar URLs to be requested
* @param contentType MIME type of requested format; may be "text/calendar" for iCalendar or
* "application/calendar+json" for jCard. *null*: don't request specific representation type
* @param version Version subtype of the requested format, like "2.0" for iCalendar 2. *null*: don't request specific version
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT calendar-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?, DAV:href+)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.insertTag(CALENDAR_MULTIGET) {
insertTag(PROP) {
insertTag(GetContentType.NAME) // to determine the character set
insertTag(GetETag.NAME)
insertTag(ScheduleTag.NAME)
insertTag(CalendarData.NAME) {
if (contentType != null)
attribute(null, CalendarData.CONTENT_TYPE, contentType)
if (version != null)
attribute(null, CalendarData.VERSION, version)
}
}
for (url in urls)
insertTag(HREF) {
serializer.text(url.encodedPath)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,123 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Logger
/**
* Represents a WebDAV collection.
*/
open class DavCollection @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavResource(httpClient, location, log) {
companion object {
val SYNC_COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "sync-collection")
val SYNC_LEVEL = Property.Name(XmlUtils.NS_WEBDAV, "sync-level")
val LIMIT = Property.Name(XmlUtils.NS_WEBDAV, "limit")
val NRESULTS = Property.Name(XmlUtils.NS_WEBDAV, "nresults")
}
/**
* Sends a POST request. Primarily intended to be used with an Add-Member URL (RFC 5995).
*/
@Throws(IOException::class, HttpException::class)
fun post(body: RequestBody, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.post(body)
.url(location)
if (ifNoneMatch)
// don't overwrite anything existing
builder.header("If-None-Match", "*")
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a REPORT sync-collection request.
*
* @param syncToken sync-token to be sent with the request
* @param infiniteDepth sync-level to be sent with the request: false = "1", true = "infinite"
* @param limit maximum number of results (may cause truncation)
* @param properties WebDAV properties to be requested
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws java.io.IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
<!ELEMENT sync-token CDATA> <!-- Text MUST be a URI -->
<!ELEMENT sync-level CDATA> <!-- Text MUST be either "1" or "infinite" -->
<!ELEMENT limit (nresults) >
<!ELEMENT nresults (#PCDATA)> <!-- only digits -->
<!-- DAV:prop defined in RFC 4918, Section 14.18 -->
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.insertTag(SYNC_COLLECTION) {
insertTag(SyncToken.NAME) {
if (syncToken != null)
text(syncToken)
}
insertTag(SYNC_LEVEL) {
text(if (infiniteDepth) "infinite" else "1")
}
if (limit != null)
insertTag(LIMIT) {
insertTag(NRESULTS) {
text(limit.toString())
}
}
insertTag(PROP) {
for (prop in properties)
insertTag(prop)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "0")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,802 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.exception.*
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.EOFException
import java.io.IOException
import java.io.Reader
import java.io.StringWriter
import java.net.HttpURLConnection
import java.util.logging.Level
import java.util.logging.Logger
import at.bitfire.dav4jvm.Response as DavResponse
/**
* Represents a WebDAV resource at the given location and allows WebDAV
* requests to be performed on this resource.
*
* Requests are executed synchronously (blocking). If no error occurs, the given
* callback will be called. Otherwise, an exception is thrown. *These callbacks
* don't need to close the response.*
*
* To cancel a request, interrupt the thread. This will cause the requests to
* throw `InterruptedException` or `InterruptedIOException`.
*
* @param httpClient [OkHttpClient] to access this object (must not follow redirects)
* @param location location of the WebDAV resource
* @param log will be used for logging
*/
open class DavResource @JvmOverloads constructor(
val httpClient: OkHttpClient,
location: HttpUrl,
val log: Logger = Dav4jvm.log
) {
companion object {
const val MAX_REDIRECTS = 5
const val HTTP_MULTISTATUS = 207
/** 301 and 308 — the two redirects a caller should persist. */
const val HTTP_MOVED_PERM = 301
const val HTTP_PERM_REDIRECT = 308
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
val PROPFIND = Property.Name(XmlUtils.NS_WEBDAV, "propfind")
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
val REMOVE = Property.Name(XmlUtils.NS_WEBDAV, "remove")
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
val HREF = Property.Name(XmlUtils.NS_WEBDAV, "href")
val XML_SIGNATURE = "<?xml".toByteArray()
/**
* Creates a request body for the PROPPATCH request.
*/
internal fun createProppatchXml(
setProperties: Map<Property.Name, String>,
removeProperties: List<Property.Name>
): String {
// build XML request body
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
serializer.startDocument("UTF-8", null)
serializer.insertTag(PROPERTYUPDATE) {
// DAV:set
if (setProperties.isNotEmpty()) {
serializer.insertTag(SET) {
for (prop in setProperties) {
serializer.insertTag(PROP) {
serializer.insertTag(prop.key) {
text(prop.value)
}
}
}
}
}
// DAV:remove
if (removeProperties.isNotEmpty()) {
serializer.insertTag(REMOVE) {
for (prop in removeProperties) {
insertTag(PROP) {
insertTag(prop)
}
}
}
}
}
serializer.endDocument()
return writer.toString()
}
}
/**
* URL of this resource (changes when being redirected by server)
*/
/**
* The location reached through an unbroken chain of **permanent** redirects
* (301, 308), or null if none was followed.
*
* Upstream mutates [location] in place for every redirect and tells the
* caller nothing, so a caller cannot distinguish "this resource has moved,
* store the new URL" from "follow this once". DAVx5 consequently never
* rewrites a stored collection URL after a 301 and re-follows it on every
* single sync — bitfireAT/dav4jvm#209. Persisting the new URL ourselves is
* the fix. Local addition; see dav/PROVENANCE.md
* change 3.
*/
var permanentLocation: HttpUrl? = null
private set
var location: HttpUrl
private set // allow internal modification only (for redirects)
init {
// Don't follow redirects (only useful for GET/POST).
// This means we have to handle 30x responses ourselves.
require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" }
this.location = location
}
override fun toString() = location.toString()
/**
* Gets the file name of this resource. See [HttpUtils.fileName] for details.
*/
fun fileName() = HttpUtils.fileName(location)
/**
* Sends an OPTIONS request to this resource without HTTP compression (because some servers have
* broken compression for OPTIONS). Doesn't follow redirects.
*
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun options(callback: CapabilitiesCallback) {
httpClient.newCall(Request.Builder()
.method("OPTIONS", null)
.header("Content-Length", "0")
.url(location)
.header("Accept-Encoding", "identity") // disable compression
.build()).execute().use { response ->
checkStatus(response)
callback.onCapabilities(
HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(),
response
)
}
}
/**
* Sends a MOVE request to this resource. Follows up to [MAX_REDIRECTS] redirects.
* Updates [location] on success.
*
* @param destination where the resource shall be moved to
* @param forceOverride whether resources are overwritten when they already exist in destination
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun move(destination: HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
val requestBuilder = Request.Builder()
.method("MOVE", null)
.header("Content-Length", "0")
.header("Destination", destination.toString())
if (forceOverride) requestBuilder.header("Overwrite", "F")
followRedirects {
requestBuilder.url(location)
httpClient.newCall(requestBuilder
.build())
.execute()
}.use { response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* Multiple resources were to be affected by the MOVE, but errors on some
of them prevented the operation from taking place.
[_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */
throw HttpException(response)
// update location
location.resolve(response.header("Location") ?: destination.toString())?.let {
location = it
}
callback.onResponse(response)
}
}
/**
* Sends a COPY request for this resource. Follows up to [MAX_REDIRECTS] redirects.
*
* @param destination where the resource shall be copied to
* @param forceOverride whether resources are overwritten when they already exist in destination
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun copy(destination:HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
val requestBuilder = Request.Builder()
.method("COPY", null)
.header("Content-Length", "0")
.header("Destination", destination.toString())
if (forceOverride) requestBuilder.header("Overwrite", "F")
followRedirects {
requestBuilder.url(location)
httpClient.newCall(requestBuilder
.build())
.execute()
}.use{ response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* Multiple resources were to be affected by the COPY, but errors on some
of them prevented the operation from taking place.
[_] (RFC 4918 9.8.5. Status Codes for COPY Method) */
throw HttpException(response)
callback.onResponse(response)
}
}
/**
* Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects.
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun mkCol(xmlBody: String?, callback: ResponseCallback) {
val rqBody = xmlBody?.toRequestBody(MIME_XML)
followRedirects {
httpClient.newCall(Request.Builder()
.method("MKCOL", rqBody)
.url(location)
.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a HEAD request to the resource.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
fun head(callback: ResponseCallback) {
followRedirects {
httpClient.newCall(
Request.Builder()
.head()
.url(location)
.build()
).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable
* compression, because compression might change the ETag.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead")
@Throws(IOException::class, HttpException::class)
fun get(accept: String, callback: ResponseCallback) =
get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback)
/**
* Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects.
*
* Note: Add `Accept-Encoding: identity` to [headers] if you want to disable compression
* (compression might change the returned ETag).
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param headers additional headers to send with the request
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
fun get(accept: String, headers: Headers?, callback: ResponseCallback) {
followRedirects {
val request = Request.Builder()
.get()
.url(location)
if (headers != null)
request.headers(headers)
// always Accept header
request.header("Accept", accept)
httpClient.newCall(request.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a GET request to the resource for a specific byte range. Make sure to check the
* response code: servers may return the whole resource with 200 or partials with 206.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param offset zero-based index of first byte to request
* @param size number of bytes to request
* @param headers additional headers to send with the request
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on high-level errors
*/
@Throws(IOException::class, HttpException::class)
fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) {
followRedirects {
val request = Request.Builder()
.get()
.url(location)
if (headers != null)
request.headers(headers)
val lastIndex = offset + size - 1
request
.header("Accept", accept)
.header("Range", "bytes=$offset-$lastIndex")
httpClient.newCall(request.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a PUT request to the resource. Follows up to [MAX_REDIRECTS] redirects.
*
* When the server returns an ETag, it is stored in response properties.
*
* @param body new resource body to upload
* @param ifETag value of `If-Match` header to set, or null to omit
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
* @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun put(body: RequestBody, ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.put(body)
.url(location)
if (ifETag != null)
// only overwrite specific version
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
if (ifScheduleTag != null)
// only overwrite specific version
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
if (ifNoneMatch)
// don't overwrite anything existing
builder.header("If-None-Match", "*")
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a DELETE request to the resource. Warning: Sending this request to a collection will
* delete the collection with all its contents!
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param ifETag value of `If-Match` header to set, or null to omit
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP errors, or when 207 Multi-Status is returned
* (because then there was probably a problem with a member resource)
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun delete(ifETag: String? = null, ifScheduleTag: String? = null, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.delete()
.url(location)
if (ifETag != null)
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
if (ifScheduleTag != null)
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* If an error occurs deleting a member resource (a resource other than
the resource identified in the Request-URI), then the response can be
a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */
throw HttpException(response)
callback.onResponse(response)
}
}
/**
* Sends a PROPFIND request to the resource. Expects and processes a 207 Multi-Status response.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param depth "Depth" header to send (-1 for `infinity`)
* @param reqProp properties to request
* @param callback called for every XML response element in the Multi-Status response
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) {
// build XML request body
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.startDocument("UTF-8", null)
serializer.insertTag(PROPFIND) {
insertTag(PROP) {
for (prop in reqProp)
insertTag(prop)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("PROPFIND", writer.toString().toRequestBody(MIME_XML))
.header("Depth", if (depth >= 0) depth.toString() else "infinity")
.build()).execute()
}.use {
processMultiStatus(it, callback)
}
}
/**
* Sends a PROPPATCH request to the server in order to set and remove properties.
*
* @param setProperties map of properties that shall be set (values currently have to be strings)
* @param removeProperties list of names of properties that shall be removed
* @param callback called for every XML response element in the Multi-Status response
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* Currently expects a 207 Multi-Status response although servers are allowed to
* return other values, too.
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
fun proppatch(
setProperties: Map<Property.Name, String>,
removeProperties: List<Property.Name>,
callback: MultiResponseCallback
) {
followRedirects {
val rqBody = createProppatchXml(setProperties, removeProperties)
httpClient.newCall(
Request.Builder()
.url(location)
.method("PROPPATCH", rqBody.toRequestBody(MIME_XML))
.build()
).execute()
}.use {
// TODO handle not only 207 Multi-Status
// http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status
processMultiStatus(it, callback)
}
}
/**
* Sends a SEARCH request (RFC 5323) with the given body to the server.
*
* Follows up to [MAX_REDIRECTS] redirects. Expects a 207 Multi-Status response.
*
* @param search search request body (XML format, DAV:searchrequest or DAV:query-schema-discovery)
* @param callback called for every XML response element in the Multi-Status response
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
fun search(search: String, callback: MultiResponseCallback) {
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("SEARCH", search.toRequestBody(MIME_XML))
.build()).execute()
}.use {
processMultiStatus(it, callback)
}
}
// status handling
/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException in case of an HTTP error
*/
protected fun checkStatus(response: Response) =
checkStatus(response.code, response.message, response)
/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException (with XML error names, if available) in case of an HTTP error
*/
private fun checkStatus(code: Int, message: String?, response: Response?) {
if (code / 100 == 2)
// everything OK
return
throw when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED ->
if (response != null) UnauthorizedException(response) else UnauthorizedException(message)
HttpURLConnection.HTTP_FORBIDDEN ->
if (response != null) ForbiddenException(response) else ForbiddenException(message)
HttpURLConnection.HTTP_NOT_FOUND ->
if (response != null) NotFoundException(response) else NotFoundException(message)
HttpURLConnection.HTTP_CONFLICT ->
if (response != null) ConflictException(response) else ConflictException(message)
HttpURLConnection.HTTP_PRECON_FAILED ->
if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message)
HttpURLConnection.HTTP_UNAVAILABLE ->
if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message)
else ->
if (response != null) HttpException(response) else HttpException(code, message)
}
}
/**
* Send a request and follows up to [MAX_REDIRECTS] redirects.
*
* @param sendRequest called to send the request (may be called multiple times)
*
* @return response of the last request (whether it is a redirect or not)
*
* @throws DavException on HTTPS -> HTTP redirect
*/
internal fun followRedirects(sendRequest: () -> Response): Response {
lateinit var response: Response
// Describes the request being made now, not one made earlier through the
// same object. DavResource instances are reused, and a stale value would
// have the caller persist a URL that a later move() already superseded.
permanentLocation = null
// A permanent target is only permanent while every hop so far has been
// permanent: 301 -> 302 means the resource moved to the 301's target and
// is being served from elsewhere temporarily.
var chainStillPermanent = true
for (attempt in 1..MAX_REDIRECTS) {
response = sendRequest()
if (response.isRedirect)
// handle 3xx Redirection
response.use {
var target = it.header("Location")?.let { location.resolve(it) }
if (target != null) {
log.fine("Redirected, new location = $target")
if (location.isHttps && !target.isHttps) {
// ⚠️ A downgrade to the *same host* is a server
// misconfiguration, not an attack, and it is the
// single most common one in this space: a Nextcloud
// behind a TLS-terminating proxy without
// `overwriteprotocol` (or `X-Forwarded-Proto`) builds
// every redirect with http://, including the
// /.well-known/caldav hop that discovery depends on.
// Refusing it outright makes a perfectly good server
// undiscoverable, and the user cannot tell why.
//
// Re-issuing the same host and path over TLS is
// strictly safer than what we were asked to do, and
// preserves the invariant that actually matters:
// credentials never travel in the clear. A downgrade
// pointing at a *different* host has no innocent
// reading, so that still fails.
if (target.host == location.host) {
target = target.newBuilder().scheme("https").build()
log.fine("Upgraded same-host downgrade back to $target")
} else {
throw DavException("Received redirect from HTTPS to HTTP")
}
}
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
permanentLocation = target
else
chainStillPermanent = false
location = target
} else
throw DavException("Redirected without new Location")
}
else
break
}
return response
}
/**
* Validates a 207 Multi-Status response.
*
* @param response will be checked for Multi-Status response
*
* @throws DavException if the response is not a Multi-Status response
*/
fun assertMultiStatus(response: Response) {
if (response.code != HTTP_MULTISTATUS)
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response)
val body = response.body ?:
throw DavException("Received 207 Multi-Status without body", httpResponse = response)
body.contentType()?.let { mimeType ->
if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") {
/* Content-Type is not application/xml or text/xml although that is expected here.
Some broken servers return an XML response with some other MIME type. So we try to see
whether the response is maybe XML although the Content-Type is something else. */
try {
val firstBytes = ByteArray(XML_SIGNATURE.size)
body.source().peek().readFully(firstBytes)
if (XML_SIGNATURE.contentEquals(firstBytes)) {
Dav4jvm.log.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType")
// response is OK, return and do not throw Exception below
return
}
} catch (e: Exception) {
Dav4jvm.log.log(Level.WARNING, "Couldn't scan for XML signature", e)
}
throw DavException("Received non-XML 207 Multi-Status", httpResponse = response)
}
} ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
}
// Multi-Status handling
/**
* Processes a Multi-Status response.
*
* @param response response which is expected to contain a Multi-Status response
* @param callback called for every XML response element in the Multi-Status response
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response)
*/
protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List<Property> {
checkStatus(response)
assertMultiStatus(response)
response.body!!.use {
return processMultiStatus(it.charStream(), callback)
}
}
/**
* Processes a Multi-Status response.
*
* @param reader the Multi-Status response is read from this
* @param callback called for every XML response element in the Multi-Status response
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like an invalid XML response)
*/
protected fun processMultiStatus(reader: Reader, callback: MultiResponseCallback): List<Property> {
val responseProperties = mutableListOf<Property>()
val parser = XmlUtils.newPullParser()
fun parseMultiStatus(): List<Property> {
// <!ELEMENT multistatus (response*, responsedescription?,
// sync-token?) >
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
DavResponse.RESPONSE ->
at.bitfire.dav4jvm.Response.parse(parser, location, callback)
SyncToken.NAME ->
XmlUtils.readText(parser)?.let {
responseProperties += SyncToken(it)
}
}
eventType = parser.next()
}
return responseProperties
}
try {
parser.setInput(reader)
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.propertyName() == DavResponse.MULTISTATUS)
return parseMultiStatus()
// ignore further <multistatus> elements
eventType = parser.next()
}
throw DavException("Multi-Status response didn't contain multistatus XML element")
} catch (e: EOFException) {
throw DavException("Incomplete multistatus XML element", e)
} catch (e: XmlPullParserException) {
throw DavException("Couldn't parse multistatus XML element", e)
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import java.io.Serializable
/**
* Represents an XML precondition/postcondition error. Every error has a name, which is the XML element
* name. Subclassed errors may have more specific information available.
*
* At the moment, there is no logic for subclassing errors.
*/
class Error(
val name: Property.Name
): Serializable {
companion object {
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "error")
fun parseError(parser: XmlPullParser): List<Error> {
val names = mutableSetOf<Property.Name>()
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
names += Property.Name(parser.namespace, parser.name)
eventType = parser.next()
}
return names.map { Error(it) }
}
// some pre-defined errors
val NEED_PRIVILEGES = Error(Property.Name(XmlUtils.NS_WEBDAV, "need-privileges"))
val VALID_SYNC_TOKEN = Error(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))
}
override fun equals(other: Any?) =
(other is Error) && other.name == name
override fun hashCode() = name.hashCode()
}
@@ -0,0 +1,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
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.
*
* ⚠️ `Locale.US`, not `Locale.ROOT`. On a desktop JVM the two agree, but
* Android's ICU root locale abbreviates months as `M10`, so every date
* formatted on a device was unparseable. See change 8.
*/
private fun httpDateFormat() = SimpleDateFormat(httpDateFormatStr, Locale.US).apply {
timeZone = GMT
}
/**
* Gets the resource name (the last segment of the path) from an URL.
* Empty if the resource is the base directory.
*
* * `dir` for `https://example.com/dir/`
* * `file` for `https://example.com/file`
* * `` for `https://example.com` or `https://example.com/`
*
* @return resource name
*/
fun fileName(url: HttpUrl): String {
val pathSegments = url.pathSegments.dropLastWhile { it == "" }
return pathSegments.lastOrNull() ?: ""
}
fun listHeader(response: Response, name: String): Array<String> {
val value = response.headers(name).joinToString(",")
return value.split(',').filter { it.isNotEmpty() }.toTypedArray()
}
/**
* Formats a date for use in HTTP headers.
*
* @param date date to be formatted
* @return date in HTTP-date format
*/
fun formatDate(date: Date): String = httpDateFormat().format(date)
/**
* Parses a HTTP-date.
*
* @param dateStr date with format specified by RFC 7231 section 7.1.1.1
* or in one of the obsolete formats (copied from okhttp internal date-parsing class)
*
* @return date, or null if date could not be parsed
*/
/**
* The formats [parseDate] accepts, in order. RFC 7231 §7.1.1.1 first, then the
* obsolete ones, then the malformed-but-real ones — copied from OkHttp's
* internal date parser, which is where upstream took them from.
*/
private val dateFormats = arrayOf(
httpDateFormatStr,
"EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ
"EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format
// Alternative formats.
"EEE, dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MMM-yyyy HH-mm-ss z",
"EEE, dd MMM yy HH:mm:ss z",
"EEE dd-MMM-yyyy HH:mm:ss z",
"EEE dd MMM yyyy HH:mm:ss z",
"EEE dd-MMM-yyyy HH-mm-ss z",
"EEE dd-MMM-yy HH:mm:ss z",
"EEE dd MMM yy HH:mm:ss z",
"EEE,dd-MMM-yy HH:mm:ss z",
"EEE,dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MM-yyyy HH:mm:ss z",
/* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */
"EEE MMM d yyyy HH:mm:ss z",
)
/**
* Parses an HTTP-date.
*
* @param dateStr date in the format of RFC 7231 §7.1.1.1, or one of the
* obsolete formats
*
* @return the date, or null if none of the formats matched
*/
fun parseDate(dateStr: String): Date? {
// Upstream used commons-lang3 DateUtils.parseDate for exactly this loop,
// and that import was the library's only use of the dependency. See
// dav/PROVENANCE.md change 1.
for (format in dateFormats) {
val parser = SimpleDateFormat(format, Locale.US).apply {
// GMT is the default for the formats that carry no zone — the
// quoted-literal 'GMT' one and ANSI C's asctime(). The patterns
// with a real `z` still take the zone from the input.
timeZone = GMT
// The obsolete formats carry two-digit years, and lenient parsing
// is what lets "dd MMM yy" resolve at all.
isLenient = true
}
val position = ParsePosition(0)
val parsed = parser.parse(dateStr, position)
// ⚠️ The whole string must be consumed. `DateUtils.parseDate` enforced
// this and `SimpleDateFormat.parse` does not: pattern 1 ends in the
// *literal* 'GMT', so "…07:28:00 GMT+02:00" matches it as a prefix and
// the offset is silently discarded — reintroducing the exact two-hour
// error change 2 exists to fix.
if (parsed != null && position.index == dateStr.length) return parsed
}
return null
}
}
@@ -0,0 +1,69 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Response.Companion.STATUS
import at.bitfire.dav4jvm.XmlUtils.propertyName
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import org.xmlpull.v1.XmlPullParser
import java.net.ProtocolException
import java.util.*
/**
* Represents a WebDAV propstat XML element.
*
* <!ELEMENT propstat (prop, status, error?, responsedescription?) >
*/
data class PropStat(
val properties: List<Property>,
val status: StatusLine,
val error: List<Error>? = null
) {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "propstat")
private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK")
private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
fun parse(parser: XmlPullParser): PropStat {
val depth = parser.depth
var status: StatusLine? = null
val prop = LinkedList<Property>()
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
DavResource.PROP ->
prop.addAll(Property.parse(parser))
STATUS ->
status = try {
StatusLine.parse(parser.nextText())
} catch (e: ProtocolException) {
// invalid status line, treat as 500 Internal Server Error
INVALID_STATUS
}
}
eventType = parser.next()
}
return PropStat(prop, status ?: ASSUMING_OK)
}
}
fun isSuccess() = status.code/100 == 2
}
@@ -0,0 +1,59 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Dav4jvm.log
import org.xmlpull.v1.XmlPullParser
import java.io.Serializable
import java.util.*
/**
* Represents a WebDAV property.
*
* Every [Property] must define a static field (use `@JvmStatic`) called `NAME` of type [Property.Name],
* which will be accessed by reflection.
*/
interface Property {
data class Name(
val namespace: String,
val name: String
): Serializable {
override fun toString() = "$namespace:$name"
}
companion object {
fun parse(parser: XmlPullParser): List<Property> {
// <!ELEMENT prop ANY >
val depth = parser.depth
val properties = LinkedList<Property>()
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
val depthBeforeParsing = parser.depth
val name = Property.Name(parser.namespace, parser.name)
val property = PropertyRegistry.create(name, parser)
assert(parser.depth == depthBeforeParsing)
if (property != null) {
properties.add(property)
} else
log.fine("Ignoring unknown property $name")
}
eventType = parser.next()
}
return properties
}
}
}
@@ -0,0 +1,26 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
interface PropertyFactory {
/**
* Name of the Property the factory creates,
* e.g. Property.Name("DAV:", "displayname") if the factory creates DisplayName objects)
*/
fun getName(): Property.Name
/**
* Parses XML of a property and returns its data class.
* @throws XmlPullParserException in case of parsing errors
*/
fun create(parser: XmlPullParser): Property?
}
@@ -0,0 +1,100 @@
/*
* 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 at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
object PropertyRegistry {
private val factories = mutableMapOf<Property.Name, PropertyFactory>()
init {
Dav4jvm.log.info("Registering DAV property factories")
registerDefaultFactories()
}
private fun registerDefaultFactories() {
register(listOf(
AddMember.Factory,
AddressbookDescription.Factory,
AddressbookHomeSet.Factory,
AddressData.Factory,
CalendarColor.Factory,
CalendarData.Factory,
CalendarDescription.Factory,
CalendarHomeSet.Factory,
CalendarProxyReadFor.Factory,
CalendarProxyWriteFor.Factory,
CalendarTimezone.Factory,
CalendarUserAddressSet.Factory,
CreationDate.Factory,
CurrentUserPrincipal.Factory,
CurrentUserPrivilegeSet.Factory,
DisplayName.Factory,
GetContentLength.Factory,
GetContentType.Factory,
GetCTag.Factory,
GetETag.Factory,
GetLastModified.Factory,
GroupMembership.Factory,
MaxICalendarSize.Factory,
MaxVCardSize.Factory,
Owner.Factory,
QuotaAvailableBytes.Factory,
QuotaUsedBytes.Factory,
ResourceType.Factory,
ScheduleTag.Factory,
Source.Factory,
SupportedAddressData.Factory,
SupportedCalendarComponentSet.Factory,
SupportedCalendarData.Factory,
SupportedReportSet.Factory,
SyncToken.Factory,
// WebDAV-Push, backported: see dav/PROVENANCE.md change 7.
PushTransports.Factory,
Topic.Factory
))
}
/**
* Registers a property factory, so that objects for all WebDAV properties which are handled
* by this factory can be created.
*
* @param factory property factory to be registered
*/
fun register(factory: PropertyFactory) {
Dav4jvm.log.fine("Registering ${factory::class.java.name} for ${factory.getName()}")
factories[factory.getName()] = factory
}
/**
* Registers some property factories, so that objects for all WebDAV properties which are handled
* by these factories can be created.
* @param factories property factories to be registered
*/
fun register(factories: Iterable<PropertyFactory>) {
factories.forEach {
register(it)
}
}
fun create(name: Property.Name, parser: XmlPullParser) =
try {
factories[name]?.create(parser)
} catch (e: XmlPullParserException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $name", e)
null
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
object QuotedStringUtils {
fun asQuotedString(raw: String) =
"\"" + raw.replace("\\" ,"\\\\").replace("\"", "\\\"") + "\""
fun decodeQuotedString(quoted: String): String {
/* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
qdtext = <any TEXT except <">>
quoted-pair = "\" CHAR
*/
val len = quoted.length
if (len >= 2 && quoted[0] == '"' && quoted[len-1] == '"') {
val result = StringBuffer(len)
var pos = 1
while (pos < len-1) {
var c = quoted[pos]
if (c == '\\' && pos != len-2)
c = quoted[++pos]
result.append(c)
pos++
}
return result.toString()
} else
return quoted
}
}
@@ -0,0 +1,222 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Dav4jvm.log
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import org.xmlpull.v1.XmlPullParser
import java.net.ProtocolException
/**
* Represents a WebDAV response XML Element.
*
* <!ELEMENT response (href, ((href*, status)|(propstat+)),
* error?, responsedescription? , location?) >
*/
data class Response(
/**
* URL of the requested resource. For instance, if `this` is a result
* of a PROPFIND request, the `requestedUrl` would be the URL where the
* PROPFIND request has been sent to (usually the collection URL).
*/
val requestedUrl: HttpUrl,
/**
* URL of this response (`href` element)
*/
val href: HttpUrl,
/**
* status of this response (`status` XML element)
*/
val status: StatusLine?,
/**
* property/status elements (`propstat` XML elements)
*/
val propstat: List<PropStat>,
/**
* list of precondition/postcondition elements (`error` XML elements)
*/
val error: List<Error>? = null,
/**
* new location of this response (`location` XML element), used for redirects
*/
val newLocation: HttpUrl? = null
) {
enum class HrefRelation {
SELF, MEMBER, OTHER
}
/**
* All properties from propstat elements with empty status or status code 2xx.
*/
val properties: List<Property> by lazy {
if (isSuccess())
propstat.filter { it.isSuccess() }.map { it.properties }.flatten()
else
emptyList()
}
/**
* Convenience method to get a certain property with empty status or status code 2xx
* from the current response.
*/
operator fun<T: Property> get(clazz: Class<T>) =
properties.filterIsInstance(clazz).firstOrNull()
/**
* Returns whether the request was successful.
*
* @return true: no status XML element or status code 2xx; false: otherwise
*/
fun isSuccess() = status == null || status.code/100 == 2
/**
* Returns the name (last path segment) of the resource.
*/
fun hrefName() = HttpUtils.fileName(href)
companion object {
val RESPONSE = Property.Name(XmlUtils.NS_WEBDAV, "response")
val MULTISTATUS = Property.Name(XmlUtils.NS_WEBDAV, "multistatus")
val STATUS = Property.Name(XmlUtils.NS_WEBDAV, "status")
val LOCATION = Property.Name(XmlUtils.NS_WEBDAV, "location")
/**
* Parses an XML response element.
*/
fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) {
val depth = parser.depth
var href: HttpUrl? = null
var status: StatusLine? = null
val propStat = mutableListOf<PropStat>()
var error: List<Error>? = null
var newLocation: HttpUrl? = null
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
when (parser.propertyName()) {
DavResource.HREF -> {
var sHref = parser.nextText()
if (!sHref.startsWith("/")) {
/* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative
URLs. However, some servers reply with relative paths. */
val firstColon = sHref.indexOf(':')
if (firstColon != -1) {
/* There are some servers which return not only relative paths, but relative paths like "a:b.vcf",
which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally.
For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"),
with "./" to allow resolving by HttpUrl. */
var hierarchical = false
try {
if (sHref.substring(firstColon, firstColon + 3) == "://")
hierarchical = true
} catch (e: IndexOutOfBoundsException) {
// no "://"
}
if (!hierarchical)
sHref = "./$sHref"
}
}
href = location.resolve(sHref)
}
STATUS ->
status = try {
StatusLine.parse(parser.nextText())
} catch(e: ProtocolException) {
log.warning("Invalid status line, treating as HTTP error 500")
StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
}
PropStat.NAME ->
PropStat.parse(parser).let { propStat += it }
Error.NAME ->
error = Error.parseError(parser)
LOCATION ->
newLocation = parser.nextText().toHttpUrlOrNull()
}
eventType = parser.next()
}
if (href == null) {
log.warning("Ignoring XML response element without valid href")
return
}
// if we know this resource is a collection, make sure href has a trailing slash
// (for clarity and resolving relative paths)
propStat.filter { it.isSuccess() }
.map { it.properties }
.filterIsInstance(ResourceType::class.java)
.firstOrNull()
?.let { type ->
if (type.types.contains(ResourceType.COLLECTION))
href = UrlUtils.withTrailingSlash(href!!)
}
//log.log(Level.FINE, "Received properties for $href", if (status != null) status else propStat)
// Which resource does this <response> represent?
val relation = when {
UrlUtils.equals(UrlUtils.omitTrailingSlash(href!!), UrlUtils.omitTrailingSlash(location)) ->
HrefRelation.SELF
else -> {
if (location.scheme == href!!.scheme && location.host == href!!.host && location.port == href!!.port) {
val locationSegments = location.pathSegments
val hrefSegments = href!!.pathSegments
// don't compare trailing slash segment ("")
var nBasePathSegments = locationSegments.size
if (locationSegments[nBasePathSegments-1] == "")
nBasePathSegments--
/* example: locationSegments = [ "davCollection", "" ]
nBasePathSegments = 1
hrefSegments = [ "davCollection", "aMember" ]
*/
var relation = HrefRelation.OTHER
if (hrefSegments.size > nBasePathSegments) {
val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] }
if (sameBasePath)
relation = HrefRelation.MEMBER
}
relation
} else
HrefRelation.OTHER
}
}
callback.onResponse(
Response(
location,
href!!,
status,
propStat,
error,
newLocation
),
relation)
}
}
}
@@ -0,0 +1,109 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl
import java.net.URI
import java.net.URISyntaxException
object UrlUtils {
/**
* Compares two URLs in WebDAV context. If two URLs are considered *equal*, both
* represent the same WebDAV resource (e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment`).
*
* It decodes %xx entities in the path, so `/my@dav` and `/my%40dav` are considered the same.
* This is important to process multi-status responses: some servers serve a multi-status
* response with href `/my@dav` when you request `/my%40dav` and vice versa.
*
* This method does not deal with trailing slashes, so if you want to compare collection URLs,
* make sure they both (don't) have a trailing slash before calling this method, for instance
* with [omitTrailingSlash] or [withTrailingSlash].
*
* @param url1 the first URL to be compared
* @param url2 the second URL to be compared
*
* @return whether [url1] and [url2] (usually) represent the same WebDAV resource
*/
fun equals(url1: HttpUrl, url2: HttpUrl): Boolean {
// if okhttp thinks the two URLs are equal, they're in any case
// (and it's a simple String comparison)
if (url1 == url2)
return true
// drop #fragment parts and convert to URI
val uri1 = url1.newBuilder().fragment(null).build().toUri()
val uri2 = url2.newBuilder().fragment(null).build().toUri()
return try {
val decoded1 = URI(uri1.scheme, uri1.schemeSpecificPart, uri1.fragment)
val decoded2 = URI(uri2.scheme, uri2.schemeSpecificPart, uri2.fragment)
decoded1 == decoded2
} catch (e: URISyntaxException) {
false
}
}
/**
* Gets the first-level domain name (without subdomains) from a host name.
* Also removes trailing dots.
*
* @param host name (e.g. `www.example.com.`)
*
* @return domain name (e.g. `example.com`)
*/
fun hostToDomain(host: String?): String? {
if (host == null)
return null
// remove optional dot at end
val withoutTrailingDot = host.removeSuffix(".")
// split into labels
val labels = withoutTrailingDot.split('.')
return if (labels.size >= 2) {
labels[labels.size - 2] + "." + labels[labels.size - 1]
} else
withoutTrailingDot
}
/**
* Ensures that a given URL doesn't have a trailing slash after member names.
* If the path is the root path (`/`), the slash is preserved.
*
* @param url URL to process (e.g. 'http://host/test1/')
*
* @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1`
*/
fun omitTrailingSlash(url: HttpUrl): HttpUrl {
val idxLast = url.pathSize - 1
val hasTrailingSlash = url.pathSegments[idxLast] == ""
return if (hasTrailingSlash)
url.newBuilder().removePathSegment(idxLast).build()
else
url
}
/**
* Ensures that a given URL has a trailing slash after member names.
*
* @param url URL to process (e.g. 'http://host/test1')
*
* @return URL with trailing slash, e.g. `http://host/test1/`
*/
fun withTrailingSlash(url: HttpUrl): HttpUrl {
val idxLast = url.pathSize - 1
val hasTrailingSlash = url.pathSegments[idxLast] == ""
return if (hasTrailingSlash)
url
else
url.newBuilder().addPathSegment("").build()
}
}
@@ -0,0 +1,102 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import org.xmlpull.v1.XmlSerializer
import java.io.IOException
object XmlUtils {
const val NS_WEBDAV = "DAV:"
const val NS_CALDAV = "urn:ietf:params:xml:ns:caldav"
const val NS_CARDDAV = "urn:ietf:params:xml:ns:carddav"
const val NS_APPLE_ICAL = "http://apple.com/ns/ical/"
const val NS_CALENDARSERVER = "http://calendarserver.org/ns/"
private val factory: XmlPullParserFactory
init {
try {
factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
} catch (e: XmlPullParserException) {
throw RuntimeException("Couldn't create XmlPullParserFactory", e)
}
}
fun newPullParser() = factory.newPullParser()!!
fun newSerializer() = factory.newSerializer()!!
@Throws(IOException::class, XmlPullParserException::class)
fun processTag(parser: XmlPullParser, name: Property.Name, processor: () -> Unit) {
val depth = parser.depth
var eventType = parser.eventType
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
processor()
eventType = parser.next()
}
}
@Throws(IOException::class, XmlPullParserException::class)
fun readText(parser: XmlPullParser): String? {
var text: String? = null
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.TEXT && parser.depth == depth)
text = parser.text
eventType = parser.next()
}
return text
}
@Throws(IOException::class, XmlPullParserException::class)
fun readTextProperty(parser: XmlPullParser, name: Property.Name): String? {
val depth = parser.depth
var eventType = parser.eventType
var result: String? = null
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
result = parser.nextText()
eventType = parser.next()
}
return result
}
@Throws(IOException::class, XmlPullParserException::class)
fun readTextPropertyList(parser: XmlPullParser, name: Property.Name, list: MutableCollection<String>) {
val depth = parser.depth
var eventType = parser.eventType
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
list.add(parser.nextText())
eventType = parser.next()
}
}
fun XmlSerializer.insertTag(name: Property.Name, contentGenerator: XmlSerializer.() -> Unit = {}) {
startTag(name.namespace, name.name)
contentGenerator(this)
endTag(name.namespace, name.name)
}
fun XmlPullParser.propertyName(): Property.Name {
val propNs = namespace
val propName = name
if (propNs == null || propName == null)
throw IllegalStateException("Current event must be START_TAG or END_TAG")
return Property.Name(propNs, propName)
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class ConflictException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message)
}
@@ -0,0 +1,144 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import okhttp3.MediaType
import okhttp3.Response
import okio.Buffer
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.Serializable
import java.lang.Long.min
import java.util.logging.Level
/**
* Signals that an error occurred during a WebDAV-related operation.
*
* This could be a logical error like when a required ETag has not been
* received, but also an explicit HTTP error.
*/
open class DavException @JvmOverloads constructor(
message: String,
ex: Throwable? = null,
/**
* An associated HTTP [Response]. Will be closed after evaluation.
*/
httpResponse: Response? = null
): Exception(message, ex), Serializable {
companion object {
const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB
fun isPlainText(type: MediaType) =
type.type == "text" ||
(type.type == "application" && type.subtype in arrayOf("html", "xml"))
}
var request: String? = null
/**
* Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP request body was textual content and could be read again.
*/
var requestBody: String? = null
private set
val response: String?
/**
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP response body was textual content.
*/
var responseBody: String? = null
private set
/**
* Precondition/postcondition XML elements which have been found in the XML response.
*/
var errors: List<Error> = listOf()
private set
init {
if (httpResponse != null) {
response = httpResponse.toString()
try {
request = httpResponse.request.toString()
httpResponse.request.body?.let { body ->
body.contentType()?.let { type ->
if (isPlainText(type)) {
val buffer = Buffer()
body.writeTo(buffer)
val baos = ByteArrayOutputStream()
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name())
}
}
}
} catch (e: Exception) {
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP request", e)
requestBody = "Couldn't read HTTP request: ${e.message}"
}
try {
// save response body excerpt
if (httpResponse.body?.source() != null) {
// response body has a source
httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body ->
body.contentType()?.let { mimeType ->
if (isPlainText(mimeType))
responseBody = body.string()
}
}
httpResponse.body?.use { body ->
body.contentType()?.let {
if (it.type in arrayOf("application", "text") && it.subtype == "xml") {
// look for precondition/postcondition XML elements
try {
val parser = XmlUtils.newPullParser()
parser.setInput(body.charStream())
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.propertyName() == Error.NAME)
errors = Error.parseError(parser)
eventType = parser.next()
}
} catch (e: XmlPullParserException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse XML response", e)
}
}
}
}
}
} catch (e: IOException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP response", e)
responseBody = "Couldn't read HTTP response: ${e.message}"
} finally {
httpResponse.body?.close()
}
} else
response = null
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class ForbiddenException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message)
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class GoneException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message)
}
@@ -0,0 +1,29 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
/**
* Signals that a HTTP error was sent by the server.
*/
open class HttpException: DavException {
var code: Int
constructor(response: Response): super(
"HTTP ${response.code} ${response.message}",
httpResponse = response
) {
code = response.code
}
constructor(code: Int, message: String?): super("HTTP $code $message") {
this.code = code
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class NotFoundException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message)
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class PreconditionFailedException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message)
}
@@ -0,0 +1,42 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.HttpUtils
import okhttp3.Response
import java.net.HttpURLConnection
import java.util.*
class ServiceUnavailableException: HttpException {
var retryAfter: Date? = null
constructor(message: String?): super(HttpURLConnection.HTTP_UNAVAILABLE, message)
constructor(response: Response): super(response) {
// Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds )
// HTTP-date = rfc1123-date | rfc850-date | asctime-date
response.header("Retry-After")?.let { after ->
retryAfter = HttpUtils.parseDate(after) ?:
// not a HTTP-date, must be delta-seconds
try {
val seconds = Integer.parseInt(after)
val cal = Calendar.getInstance()
cal.add(Calendar.SECOND, seconds)
cal.time
} catch (ignored: NumberFormatException) {
Dav4jvm.log.warning("Received Retry-After which was not a HTTP-date nor delta-seconds: $after")
null
}
}
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class UnauthorizedException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message)
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
/**
* Defined in RFC 5995 3.2.1 DAV:add-member Property (Protected).
*/
data class AddMember(
val href: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "add-member")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
AddMember(XmlUtils.readTextProperty(parser, DavResource.HREF))
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class AddressData(
val card: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "address-data")
// attributes
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT address-data (#PCDATA)>
AddressData(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class AddressbookDescription(
var description: String? = null
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-description")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT addressbook-description (#PCDATA)>
AddressbookDescription(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class AddressbookHomeSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-home-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, AddressbookHomeSet())
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
import java.util.regex.Pattern
data class CalendarColor(
val color: Int
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
private val PATTERN = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?")!!
/**
* Converts a WebDAV color from one of these formats:
* #RRGGBB (alpha = 0xFF)
* RRGGBB (alpha = 0xFF)
* #RRGGBBAA
* RRGGBBAA
* to an [Int] with alpha.
*/
@Throws(IllegalArgumentException::class)
fun parseARGBColor(davColor: String): Int {
val m = PATTERN.matcher(davColor)
if (m.find()) {
val color_rgb = Integer.parseInt(m.group(1), 16)
val color_alpha = m.group(2)?.let { Integer.parseInt(m.group(2), 16) and 0xFF } ?: 0xFF
return (color_alpha shl 24) or color_rgb
} else
throw IllegalArgumentException("Couldn't parse color value: $davColor")
}
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CalendarColor? {
XmlUtils.readText(parser)?.let {
try {
return CalendarColor(parseARGBColor(it))
} catch (e: IllegalArgumentException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse color, ignoring", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarData(
val iCalendar: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
// attributes
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-data (#PCDATA)>
CalendarData(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarDescription(
val description: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-description")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-description (#PCDATA)>
CalendarDescription(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarHomeSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-home-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarHomeSet())
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarProxyReadFor: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-read-for")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarProxyReadFor())
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarProxyWriteFor: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-write-for")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarProxyWriteFor())
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarTimezone(
val vTimeZone: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-timezone")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-timezone (#PCDATA)>
CalendarTimezone(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarUserAddressSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-user-address-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarUserAddressSet())
}
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CreationDate(
var creationDate: String
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "creationdate")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CreationDate? {
XmlUtils.readText(parser)?.let { it ->
return CreationDate(it)
}
return null
}
}
}
@@ -0,0 +1,66 @@
/*
* 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 at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
// see RFC 5397: WebDAV Current Principal Extension
data class CurrentUserPrincipal(
val href: String?,
/**
* RFC 5397 §3: the server answered 200 but the request was **not**
* authenticated. Upstream parsed only the `<href>` child, so this arrived
* indistinguishable from a conformant-but-empty element and from a server
* that simply omits the property — and a rejected credential then looks
* like a successful discovery that found nothing. Local addition; see
* dav/PROVENANCE.md change 6.
*/
val unauthenticated: Boolean = false
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
val UNAUTHENTICATED = Property.Name(XmlUtils.NS_WEBDAV, "unauthenticated")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
// <!ELEMENT current-user-principal (unauthenticated | href)>
// One pass over both children: XmlUtils.processTag consumes to the end
// tag, so it cannot be called twice on the same element.
var href: String? = null
var unauthenticated = false
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)
when (parser.propertyName()) {
DavResource.HREF -> href = XmlUtils.readText(parser)
UNAUTHENTICATED -> unauthenticated = true
}
eventType = parser.next()
}
return CurrentUserPrincipal(href, unauthenticated)
}
}
}
@@ -0,0 +1,88 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
data class CurrentUserPrivilegeSet(
// not all privileges from RFC 3744 are implemented by now
// feel free to add more if you need them for your project
var mayRead: Boolean = false,
var mayWriteProperties: Boolean = false,
var mayWriteContent: Boolean = false,
var mayBind: Boolean = false,
var mayUnbind: Boolean = false
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-privilege-set")
val PRIVILEGE = Property.Name(XmlUtils.NS_WEBDAV, "privilege")
val READ = Property.Name(XmlUtils.NS_WEBDAV, "read")
val WRITE = Property.Name(XmlUtils.NS_WEBDAV, "write")
val WRITE_PROPERTIES = Property.Name(XmlUtils.NS_WEBDAV, "write-properties")
val WRITE_CONTENT = Property.Name(XmlUtils.NS_WEBDAV, "write-content")
val BIND = Property.Name(XmlUtils.NS_WEBDAV, "bind")
val UNBIND = Property.Name(XmlUtils.NS_WEBDAV, "unbind")
val ALL = Property.Name(XmlUtils.NS_WEBDAV, "all")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CurrentUserPrivilegeSet? {
// <!ELEMENT current-user-privilege-set (privilege*)>
// <!ELEMENT privilege ANY>
val privs = CurrentUserPrivilegeSet()
XmlUtils.processTag(parser, PRIVILEGE) {
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
READ ->
privs.mayRead = true
WRITE -> {
privs.mayBind = true
privs.mayUnbind = true
privs.mayWriteProperties = true
privs.mayWriteContent = true
}
WRITE_PROPERTIES ->
privs.mayWriteProperties = true
WRITE_CONTENT ->
privs.mayWriteContent = true
BIND ->
privs.mayBind = true
UNBIND ->
privs.mayUnbind = true
ALL -> {
privs.mayRead = true
privs.mayBind = true
privs.mayUnbind = true
privs.mayWriteProperties = true
privs.mayWriteContent = true
}
}
eventType = parser.next()
}
}
return privs
}
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class DisplayName(
val displayName: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT displayname (#PCDATA) >
DisplayName(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class GetCTag(
val cTag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "getctag")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
GetCTag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class GetContentLength(
val contentLength: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontentlength")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): GetContentLength? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return GetContentLength(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
data class GetContentType(
val type: MediaType?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontenttype")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT getcontenttype (#PCDATA) >
GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull())
}
}
@@ -0,0 +1,78 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.QuotedStringUtils
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
/**
* The GetETag property.
*
* Can also be used to parse ETags from HTTP responses – just pass the raw ETag
* header value to the constructor and then use [eTag] and [weak].
*/
class GetETag(
rawETag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getetag")
fun fromResponse(response: Response) =
response.header("ETag")?.let { GetETag(it) }
}
/**
* The parsed eTag value. May be null if the tag is weak.
*/
val eTag: String?
/**
* If the tag is weak. May be null if the tag passed is null.
*/
val weak: Boolean?
init {
/* entity-tag = [ weak ] opaque-tag
weak = "W/"
opaque-tag = quoted-string
*/
var tag: String? = rawETag
if (tag != null) {
// remove trailing "W/"
if (tag.startsWith("W/") && tag.length >= 3) {
// entity tag is weak
tag = tag.substring(2)
weak = true
} else
weak = false
tag = QuotedStringUtils.decodeQuotedString(tag)
} else
weak = null
eTag = tag
}
override fun toString() = eTag ?: "(null)"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
GetETag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,40 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.*
import org.xmlpull.v1.XmlPullParser
data class GetLastModified(
var lastModified: Long
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getlastmodified")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): GetLastModified? {
// <!ELEMENT getlastmodified (#PCDATA) >
XmlUtils.readText(parser)?.let { rawDate ->
val date = HttpUtils.parseDate(rawDate)
if (date != null)
return GetLastModified(date.time)
else
Dav4jvm.log.warning("Couldn't parse Last-Modified date")
}
return null
}
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class GroupMembership: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "group-membership")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, GroupMembership())
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.*
abstract class HrefListProperty: Property {
val hrefs = LinkedList<String>()
val href
get() = hrefs.firstOrNull()
override fun toString() = "href=[" + hrefs.joinToString(", ") + "]"
abstract class Factory: PropertyFactory {
fun create(parser: XmlPullParser, list: HrefListProperty): HrefListProperty {
XmlUtils.readTextPropertyList(parser, DavResource.HREF, list.hrefs)
return list
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class MaxICalendarSize(
val maxSize: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "max-resource-size")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): MaxICalendarSize? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return MaxICalendarSize(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class MaxVCardSize(
val maxSize: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "max-resource-size")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): MaxVCardSize? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return MaxVCardSize(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class Owner: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "owner")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, Owner())
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class QuotaAvailableBytes(
val quotaAvailableBytes: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-available-bytes")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): QuotaAvailableBytes? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return QuotaAvailableBytes(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class QuotaUsedBytes(
val quotaUsedBytes: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-used-bytes")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): QuotaUsedBytes? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return QuotaUsedBytes(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,63 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class ResourceType: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") // WebDAV
val PRINCIPAL = Property.Name(XmlUtils.NS_WEBDAV, "principal") // WebDAV ACL
val ADDRESSBOOK = Property.Name(XmlUtils.NS_CARDDAV, "addressbook") // CardDAV
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") // CalDAV
val SUBSCRIBED = Property.Name(XmlUtils.NS_CALENDARSERVER, "subscribed")
}
val types = mutableSetOf<Property.Name>()
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): ResourceType? {
val type = ResourceType()
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
// use static objects to allow types.contains()
var typeName = Property.Name(parser.namespace, parser.name)
when (typeName) {
COLLECTION -> typeName = COLLECTION
PRINCIPAL -> typeName = PRINCIPAL
ADDRESSBOOK -> typeName = ADDRESSBOOK
CALENDAR -> typeName = CALENDAR
SUBSCRIBED -> typeName = SUBSCRIBED
}
type.types.add(typeName)
}
eventType = parser.next()
}
assert(parser.depth == depth)
return type
}
}
}
@@ -0,0 +1,45 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.QuotedStringUtils
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
class ScheduleTag(
rawScheduleTag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "schedule-tag")
fun fromResponse(response: Response) =
response.header("Schedule-Tag")?.let { ScheduleTag(it) }
}
/* Value: opaque-tag
opaque-tag = quoted-string
*/
val scheduleTag: String? = rawScheduleTag?.let { QuotedStringUtils.decodeQuotedString(it) }
override fun toString() = scheduleTag ?: "(null)"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
ScheduleTag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class Source: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "source")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, Source())
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
class SupportedAddressData: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "supported-address-data")
val ADDRESS_DATA_TYPE = Property.Name(XmlUtils.NS_CARDDAV, "address-data-type")
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
val types = mutableSetOf<MediaType>()
fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) }
fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) }
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedAddressData? {
val supported = SupportedAddressData()
try {
XmlUtils.processTag(parser, ADDRESS_DATA_TYPE) {
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
var type = contentType
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
}
}
} catch(e: XmlPullParserException) {
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
return null
}
return supported
}
}
}
@@ -0,0 +1,67 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
data class SupportedCalendarComponentSet(
var supportsEvents: Boolean,
var supportsTasks: Boolean,
var supportsJournal: Boolean
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
val ALLCOMP = Property.Name(XmlUtils.NS_CALDAV, "allcomp")
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedCalendarComponentSet? {
/* <!ELEMENT supported-calendar-component-set (comp+)>
<!ELEMENT comp ((allprop | prop*), (allcomp | comp*))>
<!ATTLIST comp name CDATA #REQUIRED>
*/
val components = SupportedCalendarComponentSet(false, false, false)
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
when (parser.propertyName()) {
ALLCOMP -> {
components.supportsEvents = true
components.supportsTasks = true
components.supportsJournal = true
}
COMP ->
when (parser.getAttributeValue(null, "name")?.uppercase()) {
"VEVENT" -> components.supportsEvents = true
"VTODO" -> components.supportsTasks = true
"VJOURNAL" -> components.supportsJournal = true
}
}
}
eventType = parser.next()
}
return components
}
}
}
@@ -0,0 +1,64 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
class SupportedCalendarData: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-data")
val CALENDAR_DATA_TYPE = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
val types = mutableSetOf<MediaType>()
fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) }
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedCalendarData? {
val supported = SupportedCalendarData()
try {
XmlUtils.processTag(parser, CALENDAR_DATA_TYPE) {
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
var type = contentType
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
}
}
} catch(e: XmlPullParserException) {
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
return null
}
return supported
}
}
}
@@ -0,0 +1,58 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class SupportedReportSet: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "supported-report-set")
val SUPPORTED_REPORT = Property.Name(XmlUtils.NS_WEBDAV, "supported-report")
val REPORT = Property.Name(XmlUtils.NS_WEBDAV, "report")
const val SYNC_COLLECTION = "DAV:sync-collection" // collection synchronization (RFC 6578)
}
val reports = mutableSetOf<String>()
override fun toString() = "[${reports.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedReportSet? {
/* <!ELEMENT supported-report-set (supported-report*)>
<!ELEMENT supported-report report>
<!ELEMENT report ANY>
*/
val supported = SupportedReportSet()
XmlUtils.processTag(parser, SUPPORTED_REPORT) {
XmlUtils.processTag(parser, REPORT) {
parser.nextTag()
if (parser.eventType == XmlPullParser.TEXT)
supported.reports += parser.text
else if (parser.eventType == XmlPullParser.START_TAG)
supported.reports += "${parser.namespace}${parser.name}"
}
}
return supported
}
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class SyncToken(
val token: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT sync-token #PCDATA>
SyncToken(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,73 @@
/*
* 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.push
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
/**
* `P:transports` — the push transports a resource offers.
*
* Only Web Push is modelled; any other transport is skipped. [webPush] is null
* when the server offers no Web Push at all.
*/
data class PushTransports(
val webPush: WebPush?,
) : Property {
/** The `P:web-push` transport, with the server's VAPID key if it has one. */
data class WebPush(
/** Uncompressed P-256 public key, base64url encoded. */
val vapidPublicKey: String?,
)
companion object {
@JvmField
val NAME = WebDAVPush.Transports
}
object Factory : PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): PushTransports {
var webPush: WebPush? = null
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 &&
parser.propertyName() == WebDAVPush.WebPush
) {
webPush = WebPush(vapidPublicKey = readVapidKey(parser))
}
eventType = parser.next()
}
return PushTransports(webPush)
}
/** Reads a `web-push` element to its end tag, returning its p256ecdsa key. */
private fun readVapidKey(parser: XmlPullParser): String? {
var key: String? = null
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 &&
parser.propertyName() == WebDAVPush.VapidPublicKey
) {
val type = parser.getAttributeValue(null, "type")
val text = XmlUtils.readText(parser)?.trim()
if (type == null || type == "p256ecdsa") key = text?.takeIf { it.isNotEmpty() }
}
eventType = parser.next()
}
return key
}
}
}
@@ -0,0 +1,31 @@
/*
* 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.push
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
/** `P:topic` — the server-wide identifier push messages about a resource carry. */
data class Topic(
val topic: String?,
) : Property {
companion object {
@JvmField
val NAME = WebDAVPush.Topic
}
object Factory : PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
Topic(XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() })
}
}
@@ -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.push
import at.bitfire.dav4jvm.Property
/**
* Element names of the WebDAV-Push draft (https://github.com/bitfireAT/webdav-push).
*
* Backported from upstream dav4jvm; see `dav/PROVENANCE.md` change 7.
*/
object WebDAVPush {
const val NS_WEBDAV_PUSH = "https://bitfire.at/webdav-push"
val AuthSecret = Property.Name(NS_WEBDAV_PUSH, "auth-secret")
val ContentEncoding = Property.Name(NS_WEBDAV_PUSH, "content-encoding")
val ContentUpdate = Property.Name(NS_WEBDAV_PUSH, "content-update")
val Expires = Property.Name(NS_WEBDAV_PUSH, "expires")
val PropertyUpdate = Property.Name(NS_WEBDAV_PUSH, "property-update")
val PushMessage = Property.Name(NS_WEBDAV_PUSH, "push-message")
val PushRegister = Property.Name(NS_WEBDAV_PUSH, "push-register")
val PushResource = Property.Name(NS_WEBDAV_PUSH, "push-resource")
val Subscription = Property.Name(NS_WEBDAV_PUSH, "subscription")
val SubscriptionPublicKey = Property.Name(NS_WEBDAV_PUSH, "subscription-public-key")
val Topic = Property.Name(NS_WEBDAV_PUSH, "topic")
val Transports = Property.Name(NS_WEBDAV_PUSH, "transports")
val Trigger = Property.Name(NS_WEBDAV_PUSH, "trigger")
val VapidPublicKey = Property.Name(NS_WEBDAV_PUSH, "vapid-public-key")
val WebPush = Property.Name(NS_WEBDAV_PUSH, "web-push")
val WebPushSubscription = Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")
}
@@ -0,0 +1,352 @@
/*
* 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 {
// ⚠️ Amended from upstream: https, not http. Basic over cleartext is now
// refused whether or not it was challenged — see PROVENANCE change 9. The
// cleartext behaviour this used to assert is pinned by
// `cleartextBasicIsRefusedEvenWhenChallenged` below.
@Test
fun testBasic() {
var authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder()
.url("https://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 cleartextBasicIsRefusedEvenWhenChallenged() {
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
assertNull(authenticator.authenticateRequest(original, response))
}
@Test
fun aRefusedCleartextChallengeIsNotCached() {
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
assertNull(authenticator.authenticateRequest(original, response))
// Caching it would make the *next* 401 report that the credentials did
// not work, about a credential that was never sent.
assertNull(authenticator.authenticateRequest(original, response))
}
@Test
fun cleartextBasicIsSentWhenExplicitlyAllowed() {
val authenticator = BasicDigestAuthHandler(null, "user", "password", insecureBasic = true)
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
val request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
}
@Test
fun cleartextDigestIsStillAnswered() {
// Digest never puts the password on the wire, so gating it would break a
// LAN server for nothing.
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header(
"WWW-Authenticate",
"Digest realm=\"WallyWorld\", nonce=\"abc\", qop=\"auth\"",
)
.build()
val request = authenticator.authenticateRequest(original, response)
assertTrue(request!!.header("Authorization")!!.startsWith("Digest"))
}
@Test
fun testDigestRFCExample() {
// use cnonce from example
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life")
authenticator.clientNonce = "0a4f113b"
authenticator.nonceCount.set(1)
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("qop", "auth"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("cnonce=\"0a4f113b\""))
assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestRealWorldExamples() {
var authenticator = BasicDigestAuthHandler(null, "demo", "demo")
authenticator.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI="
authenticator.nonceCount.set(1)
// example 1
var authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8"),
Pair("opaque", "df58bdff8cf60599c939187d0b5c54de")
))
var original = Request.Builder()
.method("PROPFIND", null)
.url("https://demo.group-office.eu/caldav/")
.build()
var request = authenticator.digestRequest(original, authScheme)
var auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"demo\""))
assertTrue(auth.contains("realm=\"Group-Office\""))
assertTrue(auth.contains("nonce=\"56212407212c8\""))
assertTrue(auth.contains("uri=\"/caldav/\""))
assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\""))
assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\""))
// example 2
authenticator = BasicDigestAuthHandler(null, "test", "test")
authScheme = Challenge("digest", mapOf<String?, String>( // lower case
Pair("nonce", "87c4c2aceed9abf30dd68c71"),
Pair("algorithm", "md5"),
Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="),
Pair("realm", "ieddy.ru")
))
original = Request.Builder()
.method("OPTIONS", null)
.url("https://ieddy.ru/")
.build()
request = authenticator.digestRequest(original, authScheme)
auth = request!!.header("Authorization")
assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it
assertTrue(auth.contains("username=\"test\""))
assertTrue(auth.contains("realm=\"ieddy.ru\""))
assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\""))
assertTrue(auth.contains("uri=\"/\""))
assertFalse(auth.contains("cnonce="))
assertFalse(auth.contains("nc=00000001"))
assertFalse(auth.contains("qop="))
assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\""))
assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\""))
}
@Test
fun testDigestMD5Sess() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12345")
authenticator.clientNonce = "hxk1lu63b6c7vhk"
authenticator.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "MD5-sess Example"),
Pair("qop", "auth"),
Pair("algorithm", "MD5-sess"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
/* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk =
4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686
A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed
h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") =
af2a72145775cfd08c36ad2676e89446
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"MD5-sess Example\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestMD5AuthInt() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12435")
authenticator.clientNonce = "hxk1lu63b6c7vhk"
authenticator.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "AuthInt Example"),
Pair("qop", "auth-int"),
Pair("nonce", "367sj3265s5"),
Pair("opaque", "87aaxcval4gba36")
))
/* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34
h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9
A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0
h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) =
81d07cb3b8d412b34144164124c970cb
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"AuthInt Example\""))
assertTrue(auth.contains("nonce=\"367sj3265s5\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth-int"))
assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\""))
assertTrue(auth.contains("opaque=\"87aaxcval4gba36\""))
}
@Test
fun testDigestLegacy() {
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife")
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertFalse(auth.contains("qop="))
assertFalse(auth.contains("nc="))
assertFalse(auth.contains("cnonce="))
assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testIncompleteAuthenticationRequests() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>())))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office")
))))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth")
))))
assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8")
))))
}
@Test
fun testAuthenticateNull() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
// must not crash (route may be null)
val request = Request.Builder()
.get()
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_2)
.code(200).message("OK")
.build()
authenticator.authenticate(null, response)
}
}
@@ -0,0 +1,257 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
class DavCollectionTest {
private val sampleText = "SAMPLE RESPONSE"
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test sample response for an initial sync-collection report from RFC 6578 3.8.
*/
@Test
fun testInitialSyncCollectionReport() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:multistatus xmlns:D=\"DAV:\">\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}test.doc</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\">\n" +
" <R:BoxType>Box type A</R:BoxType>\n" +
" </R:bigbox>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}vcard.vcf</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}calendar.ics</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00003-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:sync-token>http://example.com/ns/sync/1234</D:sync-token>\n" +
" </D:multistatus>")
)
var nrCalled = 0
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/test.doc") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00001-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/vcard.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00002-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/calendar.ics") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00003-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
}
}
assertEquals(3, nrCalled)
assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance(SyncToken::class.java).first().token)
}
/**
* Test sample response for an initial sync-collection report with truncation from RFC 6578 3.10.
*/
@Test
fun testInitialSyncCollectionReportWithTruncation() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:multistatus xmlns:D=\"DAV:\">\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}test.doc</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}vcard.vcf</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}removed.txt</D:href>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:response>" +
" <D:response>\n" +
" <D:href>${sampleUrl()}</D:href>\n" +
" <D:status>HTTP/1.1 507 Insufficient Storage</D:status>\n" +
" <D:error><D:number-of-matches-within-limits/></D:error>\n" +
" </D:response>" +
" <D:sync-token>http://example.com/ns/sync/1233</D:sync-token>\n" +
" </D:multistatus>")
)
var nrCalled = 0
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/test.doc") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00001-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/vcard.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00002-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/removed.txt") -> {
assertFalse(response.isSuccess())
assertEquals(404, response.status?.code)
assertEquals(Response.HrefRelation.MEMBER, relation)
nrCalled++
}
url.resolve("/dav/") -> {
assertFalse(response.isSuccess())
assertEquals(507, response.status?.code)
assertEquals(Response.HrefRelation.SELF, relation)
nrCalled++
}
}
}
assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance(SyncToken::class.java).first().token)
assertEquals(4, nrCalled)
}
/**
* Test sample response for a sync-collection report with unsupported limit from RFC 6578 3.12.
*/
@Test
fun testSyncCollectionReportWithUnsupportedLimit() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(507)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:error xmlns:D=\"DAV:\">\n" +
" <D:number-of-matches-within-limits/>\n" +
" </D:error>")
)
try {
collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(507, e.code)
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "number-of-matches-within-limits") })
assertEquals(1, e.errors.size)
}
}
@Test
fun testPost() {
val url = sampleUrl()
val dav = DavCollection(httpClient, url)
// 201 Created
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
assertEquals("POST", mockServer.takeRequest().method)
assertEquals(response.request.url, dav.location)
called = true
}
assertTrue(called)
}
}
@@ -0,0 +1,971 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.PreconditionFailedException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
class DavResourceTest {
private val sampleText = "SAMPLE RESPONSE"
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
@Before
fun startServer() {
mockServer.start()
}
@After
fun stopServer() {
mockServer.shutdown()
}
private fun sampleUrl() = mockServer.url("/dav/")
@Test
fun testCopy() {
val url = sampleUrl()
val destination = url.resolve("test")!!
/* POSITIVE TEST CASES */
// no preconditions, 201 Created, resulted in the creation of a new resource
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, false) {
called = true
}
assertTrue(called)
}
var rq = mockServer.takeRequest()
assertEquals("COPY", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertNull(rq.getHeader("Overwrite"))
// no preconditions, 204 No content, resource successfully copied to a preexisting
// destination resource
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, true) {
called = true
}
assertTrue(called)
}
rq = mockServer.takeRequest()
assertEquals("COPY", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertEquals("F", rq.getHeader("Overwrite"))
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. errors on some of resources affected by
// the COPY prevented the operation from taking place)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, false) { called = true }
fail("Expected HttpException")
}
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testDelete() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// no preconditions, 204 No Content
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
var called = false
dav.delete {
called = true
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("DELETE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertNull(rq.getHeader("If-Match"))
// precondition: If-Match / If-Schedule-Tag-Match, 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody("Resource has been deleted."))
called = false
dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") {
called = true
}
assertTrue(called)
rq = mockServer.takeRequest()
assertEquals("\"DeleteOnlyThisETag\"", rq.getHeader("If-Match"))
assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.getHeader("If-Schedule-Tag-Match"))
// 302 Moved Temporarily
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
.setHeader("Location", "/new-location")
)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
called = false
dav.delete(null) {
called = true
}
assertTrue(called)
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
dav.delete(null) { called = true }
fail("Expected HttpException")
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testFollowRedirects_302() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
var i = 0
dav.followRedirects {
if (i++ == 0)
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(302)
.message("Found")
.header("Location", "http://to.com/")
.request(Request.Builder()
.get()
.url("http://from.com/")
.build())
.body("New location!".toResponseBody())
.build()
else
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(204)
.message("No Content")
.request(Request.Builder()
.get()
.url("http://to.com/")
.build())
.build()
}.let { response ->
assertEquals(204, response.code)
assertEquals("http://to.com/".toHttpUrl(), dav.location)
}
}
@Test(expected = DavException::class)
fun testFollowRedirects_HttpsToHttp() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.followRedirects {
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(302)
.message("Found")
.header("Location", "http://to.com/")
.request(Request.Builder()
.get()
.url("https://from.com/")
.build())
.body("New location!".toResponseBody())
.build()
}
}
@Test
fun testGet() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("ETag", "W/\"My Weak ETag\"")
.setHeader("Content-Type", "application/x-test-result")
.setBody(sampleText))
var called = false
dav.get("*/*", null) { response ->
called = true
assertEquals(sampleText, response.body!!.string())
val eTag = GetETag.fromResponse(response)
assertEquals("My Weak ETag", eTag!!.eTag)
assertTrue(eTag.weak!!)
assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body!!.contentType()!!).type)
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("GET", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals("*/*", rq.getHeader("Accept"))
// 302 Moved Temporarily + 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
.setHeader("Location", "/target")
.setBody("This resource was moved."))
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("ETag", "\"StrongETag\"")
.setBody(sampleText))
called = false
dav.get("*/*", null) { response ->
called = true
assertEquals(sampleText, response.body!!.string())
val eTag = GetETag(response.header("ETag"))
assertEquals("StrongETag", eTag.eTag)
assertFalse(eTag.weak!!)
}
assertTrue(called)
mockServer.takeRequest()
rq = mockServer.takeRequest()
assertEquals("GET", rq.method)
assertEquals("/target", rq.path)
// 200 OK without ETag in response
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(sampleText))
called = false
dav.get("*/*", null) { response ->
called = true
assertNull(response.header("ETag"))
}
assertTrue(called)
}
@Test
fun testGetRange_Ok() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_PARTIAL))
var called = false
dav.getRange("*/*", 100, 342) { response ->
assertEquals("bytes=100-441", response.request.header("Range"))
called = true
}
assertTrue(called)
}
@Test
fun testMove() {
val url = sampleUrl()
val destination = url.resolve("test")!!
/* POSITIVE TEST CASES */
// no preconditions, 201 Created, new URL mapping at the destination
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, false) {
called = true
}
assertTrue(called)
assertEquals(destination, dav.location)
}
var rq = mockServer.takeRequest()
assertEquals("MOVE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertNull(rq.getHeader("Overwrite"))
// no preconditions, 204 No content, URL already mapped, overwrite
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, true) {
called = true
}
assertTrue(called)
assertEquals(destination, dav.location)
}
rq = mockServer.takeRequest()
assertEquals("MOVE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertEquals("F", rq.getHeader("Overwrite"))
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. errors on some of resources affected by
// the MOVE prevented the operation from taking place)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, false) { called = true }
fail("Expected HttpException")
}
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testOptions() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("DAV", " 1, 2 ,3,hyperactive-access"))
var called = false
dav.options { davCapabilities, _ ->
called = true
assertTrue(davCapabilities.contains("1"))
assertTrue(davCapabilities.contains("2"))
assertTrue(davCapabilities.contains("3"))
assertTrue(davCapabilities.contains("hyperactive-access"))
}
assertTrue(called)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
called = false
dav.options { davCapabilities, _ ->
called = true
assertTrue(davCapabilities.isEmpty())
}
assertTrue(called)
}
@Test
fun testPropfindAndMultiStatus() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/*** NEGATIVE TESTS ***/
// test for non-multi-status responses:
// * 500 Internal Server Error
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR))
var called = false
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected HttpException")
} catch(e: HttpException) {
assertFalse(called)
}
// * 200 OK (instead of 207 Multi-Status)
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// test for invalid multi-status responses:
// * non-XML response
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/html")
.setBody("<html></html>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * malformed XML response
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<malformed-xml>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * response without <multistatus> root element
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<test></test>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * multi-status response with invalid <status> in <response>
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>Invalid Status Line</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(500, response.status?.code)
called = true
}
assertTrue(called)
// * multi-status response with <response>/<status> element indicating failure
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>HTTP/1.1 403 Forbidden</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(403, response.status?.code)
called = true
}
assertTrue(called)
// * multi-status response with invalid <status> in <propstat>
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" </prop>" +
" <status>Invalid Status Line</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
called = true
assertEquals(Response.HrefRelation.SELF, relation)
assertTrue(response.properties.filterIsInstance(ResourceType::class.java).isEmpty())
}
assertTrue(called)
/*** POSITIVE TESTS ***/
// multi-status response without <response> elements
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'></multistatus>"))
dav.propfind(0, ResourceType.NAME) { _, _ ->
fail("Shouldn't be called")
}
// multi-status response with <response>/<status> element indicating success
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>HTTP/1.1 200 OK</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(0, response.properties.size)
}
assertTrue(called)
// multi-status response with <response>/<propstat> element
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
// multi-status response for collection with several members; incomplete (not all <resourcetype>s listed)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>" + url.toString() + "</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/dav/subcollection</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>A Subfolder</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/dav/uid@host:file</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Absolute path with @ and :</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>relative-uid@host.file</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Relative path with @</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>relative:colon.vcf</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Relative path with colon</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/something-very/else</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Not requested</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var nrCalled = 0
dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/subcollection/") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/uid@host:file") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/relative-uid@host.file") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/relative:colon.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName)
nrCalled++
}
}
}
assertEquals(4, nrCalled)
/*** SPECIAL CASES ***/
// same property is sent as 200 OK and 404 Not Found in same <response> (seen in iCloud)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>" + url.toString() + "</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" <propstat>" +
" <prop>" +
" <resourcetype/>" +
" </prop>" +
" <status>HTTP/1.1 404 Not Found</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(url.resolve("/dav/"), response.href)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
// multi-status response with <propstat> that doesn't contain <status> (=> assume 200 OK)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Without Status</displayname>" +
" </prop>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, DisplayName.NAME) { response, _ ->
called = true
assertEquals(200, response.propstat.first().status.code)
assertEquals("Without Status", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
}
@Test
fun testProppatch() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
// multi-status response with <response>/<propstat> elements
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:' xmlns:s='sample'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <s:setThis>Some Value</s:setThis>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" <propstat>" +
" <prop>" +
" <s:removeThis/>" +
" </prop>" +
" <status>HTTP/1.1 404 Not Found</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var called = false
dav.proppatch(
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
removeProperties = listOf(Property.Name("sample", "removeThis"))
) { response, hrefRelation ->
called = true
assertEquals(Response.HrefRelation.SELF, hrefRelation)
}
assertTrue(called)
}
@Test
fun testProppatch_createProppatchXml() {
val xml = DavResource.createProppatchXml(
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
removeProperties = listOf(Property.Name("sample", "removeThis"))
)
assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<d:propertyupdate xmlns:d=\"DAV:\">" +
"<d:set><d:prop><n1:setThis xmlns:n1=\"sample\">Some Value</n1:setThis></d:prop></d:set>" +
"<d:remove><d:prop><n2:removeThis xmlns:n2=\"sample\" /></d:prop></d:remove>" +
"</d:propertyupdate>", xml)
}
@Test
fun testPut() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// no preconditions, 201 Created
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED)
.setHeader("ETag", "W/\"Weak PUT ETag\""))
var called = false
dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
called = true
val eTag = GetETag.fromResponse(response)!!
assertEquals("Weak PUT ETag", eTag.eTag)
assertTrue(eTag.weak!!)
assertEquals(response.request.url, dav.location)
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("PUT", rq.method)
assertEquals(url.encodedPath, rq.path)
assertNull(rq.getHeader("If-Match"))
assertNull(rq.getHeader("If-None-Match"))
// precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
.setHeader("Location", "/target"))
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response ->
called = true
assertEquals(url.resolve("/target"), response.request.url)
val eTag = GetETag.fromResponse(response)
assertNull("Weak PUT ETag", eTag?.eTag)
assertNull(eTag?.weak)
}
assertTrue(called)
mockServer.takeRequest()
rq = mockServer.takeRequest()
assertEquals("PUT", rq.method)
assertEquals("*", rq.getHeader("If-None-Match"))
// precondition: If-Match, 412 Precondition Failed
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_PRECON_FAILED))
called = false
try {
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") {
called = true
}
fail("Expected PreconditionFailedException")
} catch(_: PreconditionFailedException) {}
assertFalse(called)
rq = mockServer.takeRequest()
assertEquals("\"ExistingETag\"", rq.getHeader("If-Match"))
assertNull(rq.getHeader("If-None-Match"))
}
@Test
fun testSearch() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Found something</displayname>" +
" </prop>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var called = false
dav.search("<TEST/>") { response, hrefRelation, ->
assertEquals(Response.HrefRelation.SELF, hrefRelation)
assertEquals("Found something", response[DisplayName::class.java]?.displayName)
called = true
}
assertTrue(called)
val rq = mockServer.takeRequest()
assertEquals("SEARCH", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals("<TEST/>", rq.body.readString(Charsets.UTF_8))
}
/** test helpers **/
@Test(expected = DavException::class)
fun testAssertMultiStatus_NoBody_NoXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.build())
}
@Test(expected = DavException::class)
fun testAssertMultiStatus_NoBody_XML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/xml")
.build())
}
@Test
fun testAssertMultiStatus_NonXML_ButContentIsXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "application/octet-stream")
.body("<?xml version=\"1.0\"><test/>".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_NonXML_ReallyNotXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/plain")
.body("Some error occurred".toResponseBody())
.build())
}
@Test(expected = DavException::class)
fun testAssertMultiStatus_Not207() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(403).message("Multi-Status")
.addHeader("Content-Type", "application/xml")
.body("".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_Ok_ApplicationXml() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "application/xml")
.body("".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_Ok_TextXml() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/xml")
.body("".toResponseBody())
.build())
}
}
@@ -0,0 +1,20 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertTrue
import org.junit.Test
class ErrorTest {
@Test
fun testEquals() {
val errors = listOf(Error(Property.Name("DAV:", "valid-sync-token")))
assertTrue(errors.contains(Error.VALID_SYNC_TOKEN))
}
}
@@ -0,0 +1,25 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Test
class HttpUtilsTest {
@Test
fun testFilename() {
assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl()))
assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl()))
assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl()))
assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl()))
assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl()))
assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl()))
}
}
@@ -0,0 +1,424 @@
/*
* 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.CurrentUserPrincipal
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
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
* we were expected to 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() {
// "Handles 301/302/307/308 but not 303" was listed 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 6: <D:unauthenticated/> is parsed, not inferred ---------------
private fun principalBody(inner: String) = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""
<multistatus xmlns="DAV:"><response><href>/dav/</href>
<propstat><prop><current-user-principal>$inner</current-user-principal></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response></multistatus>
""".trimIndent(),
)
private fun principalOf(inner: String): CurrentUserPrincipal? {
mockServer.enqueue(principalBody(inner))
var found: CurrentUserPrincipal? = null
DavResource(httpClient, mockServer.url("/dav/"))
.propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
found = response[CurrentUserPrincipal::class.java]
}
return found
}
@Test
fun unauthenticatedIsDistinguishableFromAMerelyEmptyElement() {
// RFC 5397 §3: a 200 whose body says the request was not authenticated.
// Upstream parsed only <href>, so this arrived identical to an empty
// element and to a server that omits the property — and a rejected
// credential then looks like a discovery that simply found nothing.
assertTrue(principalOf("<unauthenticated/>")!!.unauthenticated)
val empty = principalOf("")
assertNull(empty!!.href)
assertFalse(empty.unauthenticated)
}
@Test
fun anOrdinaryPrincipalHrefStillParses() {
val principal = principalOf("<href>/principals/me/</href>")
assertEquals("/principals/me/", principal!!.href)
assertFalse(principal.unauthenticated)
}
// -- change 1: commons-lang3 removed from date parsing --------------------
/**
* Runs [block] with a deliberately non-UTC default zone.
*
* The defect change 2 fixes *is* "the formatter used the default zone", so a
* test that does not move the default zone passes identically before and
* after the fix — and CI containers default to UTC. Without this the whole
* change is pinned by nothing.
*/
private fun <T> inNonUtcZone(block: () -> T): T {
val previous = TimeZone.getDefault()
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")) // UTC+05:30
try {
return block()
} finally {
TimeZone.setDefault(previous)
}
}
@Test
fun formatDateEmitsGmtRatherThanLocalTimeLabelledGmt() = inNonUtcZone {
assertEquals(
"Wed, 21 Oct 2015 07:28:00 GMT",
HttpUtils.formatDate(java.util.Date(1445412480000L)),
)
}
@Test
fun parseDateAcceptsTheRfc7231Format() = inNonUtcZone {
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
}
@Test
fun parseDateHonoursAnExplicitOffsetInsteadOfMatchingGmtAsAPrefix() {
// Pattern 1 ends in the *literal* 'GMT', and SimpleDateFormat.parse is
// happy with a prefix — so without a full-consumption check the "+02:00"
// is discarded and the result is two hours out, which is the same class of
// error change 2 set out to remove.
assertEquals(1445405280000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT+02:00")!!.time)
assertEquals(1445430480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT-05:00")!!.time)
}
@Test
fun parseDateRejectsTrailingGarbageRatherThanMatchingAPrefix() {
assertNull(HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT and then some"))
}
@Test
fun parseDateAcceptsTheObsoleteFormats() {
// RFC 850, and ANSI C asctime() — both still emitted in the wild.
assertTrue(HttpUtils.parseDate("Wednesday, 21-Oct-15 07:28:00 GMT") != null)
assertTrue(HttpUtils.parseDate("Wed Oct 21 07:28:00 2015") != null)
}
@Test
fun parseDateReturnsNullRatherThanThrowing() {
assertNull(HttpUtils.parseDate("not a date at all"))
}
@Test
fun parseDateIsIndependentOfTheDefaultLocale() {
// SimpleDateFormat defaults to the platform locale, which would reject
// English month names on a Turkish or German device.
val locale = Locale.getDefault()
val zone = TimeZone.getDefault()
try {
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Istanbul"))
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
} finally {
Locale.setDefault(locale)
TimeZone.setDefault(zone)
}
}
// --- change 7: a same-host downgrade is upgraded, a cross-host one is not ---
/**
* A TLS MockWebServer, because the branch under test only exists on HTTPS.
*
* Same trap as change 2's: a test that runs against plain HTTP here would
* pass whatever the code did, since `location.isHttps` gates the whole
* decision.
*/
private fun httpsServer(): Pair<MockWebServer, OkHttpClient> {
val certificate = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val serverCertificates = HandshakeCertificates.Builder()
.heldCertificate(certificate)
.build()
val clientCertificates = HandshakeCertificates.Builder()
.addTrustedCertificate(certificate.certificate)
.build()
val tlsServer = MockWebServer().apply {
useHttps(serverCertificates.sslSocketFactory(), false)
start()
}
val client = OkHttpClient.Builder()
.followRedirects(false)
.sslSocketFactory(
clientCertificates.sslSocketFactory(),
clientCertificates.trustManager,
)
.build()
return tlsServer to client
}
@Test
fun `a same-host redirect to http is retried over https`() {
// ⚠️ Exactly what a Nextcloud behind a proxy without `overwriteprotocol`
// sends for /.well-known/caldav. Refusing it makes a working CalDAV
// server report as "not a CalDAV server".
val (tls, client) = httpsServer()
val resource = DavResource(client, tls.url("/.well-known/caldav"))
val downgrade = "http://${tls.hostName}:${tls.port}/remote.php/dav/"
tls.enqueue(MockResponse().setResponseCode(301).setHeader("Location", downgrade))
tls.enqueue(MockResponse().setResponseCode(200))
resource.head { }
tls.takeRequest()
val followed = tls.takeRequest()
assertEquals("/remote.php/dav/", followed.path)
// The scheme was put back; the host and path are the server's own.
assertEquals("https", resource.location.scheme)
assertEquals("/remote.php/dav/", resource.location.encodedPath)
}
@Test
fun `a cross-host redirect to http is still refused`() {
val (tls, client) = httpsServer()
val resource = DavResource(client, tls.url("/dav/"))
tls.enqueue(
MockResponse().setResponseCode(301)
.setHeader("Location", "http://elsewhere.example.com/dav/"),
)
// No innocent reading of this one, so it stays fatal.
assertThrows(at.bitfire.dav4jvm.exception.DavException::class.java) { resource.head { } }
}
// --- change 10: the Digest challenge beside a Basic one is read ----------
@Test
fun `a Basic challenge no longer hides the Digest one in the same header`() {
val handler = BasicDigestAuthHandler("example.com", "user", "pw")
val request = okhttp3.Request.Builder().url("https://cloud.example.com/dav/").build()
// The interceptor primes Basic preemptively over HTTPS, so by the time
// the first 401 arrives a Basic challenge is already cached — which is
// what made the abort fire on every server that offers both.
handler.authenticateRequest(request, null)
val challenged = handler.authenticateRequest(
request,
run {
okhttp3.Response.Builder()
.request(request)
.protocol(okhttp3.Protocol.HTTP_1_1)
.code(401)
.message("Unauthorized")
.addHeader("WWW-Authenticate", "Basic realm=\"dav\"")
.addHeader(
"WWW-Authenticate",
"Digest realm=\"dav\", nonce=\"abc\", qop=\"auth\"",
)
.build()
},
)
assertTrue(challenged!!.header("Authorization")!!.startsWith("Digest"))
}
// --- change 11: the digest counter is per handler and per nonce ----------
@Test
fun `the nonce count restarts when the server issues a new nonce`() {
val handler = BasicDigestAuthHandler(null, "user", "pw")
val request = okhttp3.Request.Builder().url("https://example.com/dav/").build()
fun answer(nonce: String) = handler.digestRequest(
request,
okhttp3.Challenge(
"Digest",
mapOf<String?, String>("realm" to "dav", "nonce" to nonce, "qop" to "auth"),
),
)!!.header("Authorization")!!
assertTrue(answer("one").contains("nc=00000001"))
assertTrue(answer("one").contains("nc=00000002"))
// RFC 7616 3.4.1 counts requests sent with *that* nonce.
assertTrue(answer("two").contains("nc=00000001"))
}
@Test
fun `two accounts do not interleave their nonce counts`() {
val request = okhttp3.Request.Builder().url("https://example.com/dav/").build()
val challenge = okhttp3.Challenge(
"Digest",
mapOf<String?, String>("realm" to "dav", "nonce" to "n", "qop" to "auth"),
)
val first = BasicDigestAuthHandler(null, "one", "pw")
val second = BasicDigestAuthHandler(null, "two", "pw")
first.digestRequest(request, challenge)
val theirs = second.digestRequest(request, challenge)!!.header("Authorization")!!
assertTrue(theirs.contains("nc=00000001"))
}
// --- change 12: qop list values are trimmed -----------------------------
@Test
fun `a spaced qop list still selects auth-int`() {
val handler = BasicDigestAuthHandler(null, "user", "pw")
val request = okhttp3.Request.Builder().url("https://example.com/dav/").build()
val header = handler.digestRequest(
request,
okhttp3.Challenge(
"Digest",
mapOf<String?, String>(
"realm" to "dav",
"nonce" to "n",
"qop" to "auth, auth-int",
),
),
)!!.header("Authorization")!!
assertTrue(header.contains("qop=auth-int"))
}
@Test
fun `a single spaced qop value does not fall back to RFC 2069`() {
val handler = BasicDigestAuthHandler(null, "user", "pw")
val request = okhttp3.Request.Builder().url("https://example.com/dav/").build()
val header = handler.digestRequest(
request,
okhttp3.Challenge(
"Digest",
mapOf<String?, String>("realm" to "dav", "nonce" to "n", "qop" to " auth"),
),
)!!.header("Authorization")!!
// The legacy branch emits no qop, nc or cnonce, which an RFC 7616 server
// rejects outright — a permanent 401 against a server behaving legally.
assertTrue(header.contains("qop=auth"))
assertTrue(header.contains("nc="))
assertTrue(header.contains("cnonce="))
}
}
@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertEquals
import org.junit.Test
class QuotedStringUtilsTest {
@Test
fun testAsQuotedString() {
assertEquals("\"\"", QuotedStringUtils.asQuotedString(""))
assertEquals("\"\\\"\"", QuotedStringUtils.asQuotedString("\""))
assertEquals("\"\\\\\"", QuotedStringUtils.asQuotedString("\\"))
}
fun testDecodeQuotedString() {
assertEquals("\"", QuotedStringUtils.decodeQuotedString("\""))
assertEquals("\\", QuotedStringUtils.decodeQuotedString("\"\\\""))
assertEquals("\"test", QuotedStringUtils.decodeQuotedString("\"test"))
assertEquals("test", QuotedStringUtils.decodeQuotedString("test"))
assertEquals("", QuotedStringUtils.decodeQuotedString("\"\""))
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"test\""))
assertEquals("test\\", QuotedStringUtils.decodeQuotedString("\"test\\\""))
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"t\\e\\st\""))
assertEquals("12\"34", QuotedStringUtils.decodeQuotedString("\"12\\\"34\""))
assertEquals("1234\"", QuotedStringUtils.decodeQuotedString("\"1234\\\"\""))
}
}
@@ -0,0 +1,58 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.junit.Assert.*
import org.junit.Test
class UrlUtilsTest {
@Test
fun testEquals() {
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("http://host:80/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("https://HOST:443/resource".toHttpUrlOrNull()!!, "https://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("https://host:443/my@dav/".toHttpUrlOrNull()!!, "https://host/my%40dav/".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource#frag1".toHttpUrlOrNull()!!))
// should work, but currently doesn't (see MR #5)
// assertTrue(UrlUtils.equals(HttpUrl.parse("https://host/%5bresource%5d/")!!, HttpUrl.parse("https://host/[resource]/")!!))
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource/".toHttpUrlOrNull()!!))
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host:81/resource".toHttpUrlOrNull()!!))
}
@Test
fun testHostToDomain() {
assertNull(UrlUtils.hostToDomain(null))
assertEquals("", UrlUtils.hostToDomain("."))
assertEquals("com", UrlUtils.hostToDomain("com"))
assertEquals("com", UrlUtils.hostToDomain("com."))
assertEquals("example.com", UrlUtils.hostToDomain("example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("example.com."))
assertEquals("example.com", UrlUtils.hostToDomain(".example.com"))
assertEquals("example.com", UrlUtils.hostToDomain(".example.com."))
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com."))
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com."))
}
@Test
fun testOmitTrailingSlash() {
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
}
@Test
fun testWithTrailingSlash() {
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
}
}
@@ -0,0 +1,97 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.xmlpull.v1.XmlPullParser
import java.io.StringReader
class XmlUtilsTest {
@Test
fun testProcessTagRoot() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test></test>"))
// now on START_DOCUMENT [0]
var processed = false
XmlUtils.processTag(parser, Property.Name("", "test")) {
processed = true
}
assertTrue(processed)
}
@Test
fun testProcessTagDepth1() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><test></test></root>"))
parser.next() // now on START_TAG <root>
var processed = false
XmlUtils.processTag(parser, Property.Name("", "test")) {
processed = true
}
assertTrue(processed)
}
@Test
fun testReadText() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><test>Test 1</test><test><garbage/>Test 2</test></root>"))
parser.next()
parser.next() // now on START_TAG <test>
assertEquals("Test 1", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
parser.next()
assertEquals("Test 2", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
}
@Test
fun testReadTextCDATA() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test><![CDATA[Test 1</test><test><garbage/>Test 2]]></test>"))
parser.next() // now on START_TAG <test>
assertEquals("Test 1</test><test><garbage/>Test 2", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
}
@Test
fun testReadTextPropertyRoot() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><entry>Test 1</entry><entry>Test 2</entry></root>"))
parser.next() // now on START_TAG <root>
val entries = mutableListOf<String>()
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
assertEquals("Test 1", entries[0])
assertEquals("Test 2", entries[1])
parser.next() // END_TAG </root>
assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType)
}
@Test
fun testReadTextPropertyListDepth1() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test><entry>Test 1</entry><entry>Test 2</entry></test>"))
parser.next() // now on START_TAG <test> [1]
val entries = mutableListOf<String>()
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
assertEquals("Test 1", entries[0])
assertEquals("Test 2", entries[1])
assertEquals(XmlPullParser.END_TAG, parser.eventType)
assertEquals("test", parser.name)
}
}
@@ -0,0 +1,176 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class DavExceptionTest {
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test truncation of a too large plain text request in [DavException].
*/
@Test
fun testRequestLargeTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val builder = StringBuilder()
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' })
val body = builder.toString()
val e = DavException("Error with large request body", null, Response.Builder()
.request(Request.Builder()
.url("http://example.com")
.post(body.toRequestBody("text/plain".toMediaType()))
.build())
.protocol(Protocol.HTTP_1_1)
.code(204)
.message("No Content")
.build())
assertTrue(e.errors.isEmpty())
assertEquals(
body.substring(0, DavException.MAX_EXCERPT_SIZE),
e.requestBody
)
}
/**
* Test a large HTML response which has a multi-octet UTF-8 character
* exactly at the cut-off position.
*/
@Test
fun testResponseLargeTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val builder = StringBuilder()
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' })
builder.append("\u03C0") // Pi
val body = builder.toString()
mockServer.enqueue(MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "text/html")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 404)
assertTrue(e.errors.isEmpty())
assertEquals(
body.substring(0, DavException.MAX_EXCERPT_SIZE-1),
e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1)
)
}
}
@Test
fun testResponseNonTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(403)
.setHeader("Content-Type", "application/octet-stream")
.setBody("12345"))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 403)
assertTrue(e.errors.isEmpty())
assertNull(e.responseBody)
}
}
@Test
fun testSerialization() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(500)
.setHeader("Content-Type", "text/plain")
.setBody("12345"))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected DavException")
} catch (e: DavException) {
val baos = ByteArrayOutputStream()
val oos = ObjectOutputStream(baos)
oos.writeObject(e)
val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray()))
val e2 = ois.readObject() as HttpException
assertEquals(500, e2.code)
assertTrue(e2.responseBody!!.contains("12345"))
}
}
/**
* Test precondition XML element (sample from RFC 4918 16)
*/
@Test
fun testXmlError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val body = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<D:error xmlns:D=\"DAV:\">\n" +
" <D:lock-token-submitted>\n" +
" <D:href>/workspace/webdav/</D:href>\n" +
" </D:lock-token-submitted>\n" +
"</D:error>\n"
mockServer.enqueue(MockResponse()
.setResponseCode(423)
.setHeader("Content-Type", "application/xml; charset=\"utf-8\"")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 423)
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted") })
assertEquals(body, e.responseBody)
}
}
}
@@ -0,0 +1,43 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertTrue
import org.junit.Test
class HttpExceptionTest {
private val responseMessage = "Unknown error"
@Test
fun testHttpFormatting() {
val request = Request.Builder()
.post("REQUEST\nBODY".toRequestBody("text/something".toMediaType()))
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(500)
.message(responseMessage)
.body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType()))
.build()
val e = HttpException(response)
assertTrue(e.message!!.contains("500"))
assertTrue(e.message!!.contains(responseMessage))
assertTrue(e.requestBody!!.contains("REQUEST\nBODY"))
assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE"))
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.HttpUtils
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Assert.*
import org.junit.Test
import java.util.*
import kotlin.math.abs
class ServiceUnavailableExceptionTest {
val response503 = Response.Builder()
.request(Request.Builder()
.url("http://www.example.com")
.get()
.build())
.protocol(Protocol.HTTP_1_1)
.code(503).message("Try later")
.build()
@Test
fun testRetryAfter_NoTime() {
val e = ServiceUnavailableException(response503)
assertNull(e.retryAfter)
}
@Test
fun testRetryAfter_Seconds() {
val response = response503.newBuilder()
.header("Retry-After", "120")
.build()
val e = ServiceUnavailableException(response)
assertNotNull(e.retryAfter)
assertTrue(withinTimeRange(e.retryAfter!!, 120))
}
@Test
fun testRetryAfter_Date() {
val cal = Calendar.getInstance()
cal.add(Calendar.MINUTE, 30)
val response = response503.newBuilder()
.header("Retry-After", HttpUtils.formatDate(cal.time))
.build()
val e = ServiceUnavailableException(response)
assertNotNull(e.retryAfter)
assertTrue(withinTimeRange(e.retryAfter!!, 30*60))
}
private fun withinTimeRange(d: Date, seconds: Int): Boolean {
val msCheck = d.time
val msShouldBe = Date().time + seconds*1000
// assume max. 5 seconds difference for test running
return abs(msCheck - msShouldBe) < 5000
}
}
@@ -0,0 +1,15 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.assertEquals
import org.junit.Test
class CalendarDescriptionTest: PropertyTest() {
@Test
fun testCalendarDescription() {
val results = parseProperty("<calendar-description xmlns=\"urn:ietf:params:xml:ns:caldav\">My Calendar</calendar-description>")
val result = results.first() as CalendarDescription
assertEquals("My Calendar", result.description)
}
}
@@ -0,0 +1,48 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.*
import org.junit.Test
class GetETagTest: PropertyTest() {
@Test
fun testGetETag_NoText() {
val results = parseProperty("<getetag xmlns=\"DAV:\"><invalid/></getetag>")
val getETag = results.first() as GetETag
assertNull(getETag.eTag)
assertNull(getETag.weak)
}
@Test
fun testGetETag_Strong() {
val results = parseProperty("<getetag xmlns=\"DAV:\">\"Correct strong ETag\"</getetag>")
val getETag = results.first() as GetETag
assertEquals("Correct strong ETag", getETag.eTag)
assertFalse(getETag.weak!!)
}
@Test
fun testGetETag_Strong_NoQuotes() {
val results = parseProperty("<getetag xmlns=\"DAV:\">Strong ETag without quotes</getetag>")
val getETag = results.first() as GetETag
assertEquals("Strong ETag without quotes", getETag.eTag)
assertFalse(getETag.weak!!)
}
@Test
fun testGetETag_Weak() {
val results = parseProperty("<getetag xmlns=\"DAV:\">W/\"Correct weak ETag\"</getetag>")
val getETag = results.first() as GetETag
assertEquals("Correct weak ETag", getETag.eTag)
assertTrue(getETag.weak!!)
}
@Test
fun testGetETag_Weak_NoQuotes() {
val results = parseProperty("<getetag xmlns=\"DAV:\">W/Weak ETag without quotes</getetag>")
val getETag = results.first() as GetETag
assertEquals("Weak ETag without quotes", getETag.eTag)
assertTrue(getETag.weak!!)
}
}
@@ -0,0 +1,30 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class OwnerTest: PropertyTest() {
@Test
fun testOwner_PlainText() {
val results = parseProperty("<owner xmlns=\"DAV:\">https://example.com</owner>")
val owner = results.first() as Owner
assertNull(owner.href)
}
@Test
fun testOwner_PlainTextAndHref() {
val results = parseProperty("<owner xmlns=\"DAV:\">Principal Name. <href>mailto:owner@example.com</href> (test)</owner>")
val owner = results.first() as Owner
assertEquals("mailto:owner@example.com", owner.href)
}
@Test
fun testOwner_Href() {
val results = parseProperty("<owner xmlns=\"DAV:\"><href>https://example.com</href></owner>")
val owner = results.first() as Owner
assertEquals("https://example.com", owner.href)
}
}
@@ -0,0 +1,20 @@
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import java.io.StringReader
open class PropertyTest {
companion object {
fun parseProperty(s: String): List<Property> {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test>$s</test>"))
parser.nextTag() // move into <test>
return Property.parse(parser)
}
}
}