Nextcloud compatibility
Which Nextcloud apps and which versions were tried against Rostdav, what works and what does not, with the evidence.
Status: server side recorded, app side not yet. M3 recorded a real Nextcloud server with synthetic requests (written by
scripts/record_nextcloud.py, not sent by the Android app). Responses are authentic; what the app actually sends is still open. Every statement names its source: client code (repository, file, line, version), Nextcloud documentation (URL) or fixture (tests/fixtures/nextcloud/…).
Reference versions
| Component | Version | Fixed on |
|---|---|---|
| Nextcloud Android | [enter when M3 starts] | |
| Nextcloud iOS | optional, later | |
| Nextcloud server for recordings | 35.0.2 (nextcloud:apache Docker image, SQLite) | 2026-10-21 |
Reported server version (status.php) | [to be entered] |
Supported flows
| Flow | Status | Contract test | Device test | Source |
|---|---|---|---|---|
Add server (status.php) | contract test green | crates/rostdav/tests/signin.rs | – | 01-discovery/01-status |
| Sign-in via Login Flow v2 | contract test green (shapes only) | signin.rs | – | 02-login-flow-v2/ |
| List folder | contract test green (property sets) | nextcloud_dav.rs | – | 04-webdav/01, 16 |
| Upload file (small) | contract test green | crates/rostdav/tests/nextcloud_dav.rs | – | 04-webdav/10, 08-m5-probes/ |
| Upload file (> 100 MB, chunked upload v2) | contract test green (105 MiB) | nextcloud_dav.rs | – | 05-chunked-upload/, 08-m5-probes/ |
Search by name (WebDAV SEARCH, unified search files) | contract test green | search_dav.rs, search_ocs.rs | Android 35.0.1 on the emulator, 2026-10-24: unified search works (see Search) | 12-search/ |
| Download | contract test green | dav.rs, nextcloud_dav.rs | – | 04-webdav/17–19 |
| Rename, move | contract test green | nextcloud_dav.rs | – | 04-webdav/23–26 |
| Delete | contract test green | dav.rs | – | 04-webdav/27–29 |
| Sign out / revoke app password | contract test green | signin.rs | – | 07-logout/ |
Status: open · implemented · contract test green · device test passed
Recording setup
docker run -d --name nc-ref -p 127.0.0.1:18081:80 -e SQLITE_DATABASE=nc \
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin-ref-password-1 \
-e NEXTCLOUD_TRUSTED_DOMAINS="localhost:18081 127.0.0.1:18081" nextcloud:apache
python3 scripts/record_nextcloud.py # rewrites tests/fixtures/nextcloud/
Fixtures are JSON (request, response, note), sanitized: host → {{host}}, passwords/tokens/cookies replaced by placeholders, HTML pages reduced to status, headers and length. Dates, ETags and file IDs are real values and must be ignored or normalized by contract tests. The instance ID in oc:id / OC-FileId (00000125ocb0ngxhatbv) is not a secret.
Findings (server side, Nextcloud 35.0.2)
Each line cites its fixture below tests/fixtures/nextcloud/. "Differs" marks where Rostdav M2 behaves differently; these are inputs for M4/M5, not yet decisions.
Discovery and login
GET /status.php→ 200 JSONinstalled, maintenance, needsDbUpgrade, version, versionstring, edition, productname, extendedSupport(01-discovery/01-status).GET /→ 302;/.well-known/caldav→ 301 (01-discovery/03,04).- Login Flow v2:
POST /index.php/login/v2→ 200{poll:{token,endpoint}, login};POST <endpoint>withtoken=…→ 404 until granted, then 200{server, loginName, appPassword}(02-login-flow-v2/01,02,08). TheloginURL redirects (303) to/login/v2/flow; the browser then logs in viaPOST /login(needsOrigin/Refererand arequesttokenor it answersinvalidOrigin), lands on/login/v2/grantand confirms withPOST /login/v2/grant(stateToken,requesttokenheader) (02-login-flow-v2/03–07).
OCS
- Requests without
OCS-APIRequest: true→ 412{"message":"CSRF check failed"}(03-ocs/03). - Not logged in / wrong password → HTTP 401, body
{"ocs":{"meta":{"status":"failure","statuscode":997,"message":"Current user is not logged in"|"Unauthorised"},"data":[]}}, noWWW-Authenticate(03-ocs/07,08). capabilitieslistscore, dav, files, files_sharing, notifications, …. Relevant:core.webdav-root = remote.php/webdav,dav.chunking = "1.0",files.bigfilechunking = true,files.chunked_upload = {max_size: 104857600, max_parallel_count: 5},files.forbidden_filename_extensions = [".filepart", ".part"](03-ocs/01).cloud/userreturnsid, displayname, email, quota{free,used,total,relative,quota}, groups, language, …(03-ocs/04).
WebDAV
- Error bodies are Sabre XML:
<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns"><s:exception>…</s:exception><s:message>…</s:message></d:error>(04-webdav/08,13,05). Rostdav M2 sends empty bodies. Open: which parts the app evaluates. - 401 on WebDAV:
WWW-Authenticate: Basic realm="Nextcloud", charset="UTF-8"(04-webdav/06). OPTIONS:DAV: 1, 3, extended-mkcol, access-control, …, 2;AllowincludesPROPPATCH, REPORT, LOCK, UNLOCK(04-webdav/32). Differs: Rostdav advertises class 1 only.- PROPFIND:
oc:id/OC-FileId= 8-digit zero-padded numeric ID + instance ID (00000078ocb0ngxhatbv),oc:fileid= plain number,oc:permissionse.g.RGDNVCK(dir) /RGDNVW(file), ETag on files is an MD5-like string, on folders a short hex string (04-webdav/16). Properties the server does not have come back in a 404propstat. PROPFIND Depth: infinity→ 207 without entries (04-webdav/04). Differs: Rostdav answers 403propfind-finite-depth.PUT: 201 new / 204 replace; response headersOC-FileId,ETag,OC-ETag,X-OC-MTime: accepted(04-webdav/10,11). Differs: Rostdav sets onlyETag.PUTinto a missing parent → 404 (04-webdav/12);MKCOLinto a missing parent → 409 (04-webdav/09). Differs: Rostdav answers 409 for both.MKCOLon an existing name → 405 (04-webdav/08);If-None-Match: *on an existing file → 412 (04-webdav/13); staleIf-Match→ 412 (04-webdav/14); staleIf-Matchon DELETE → 412 (04-webdav/27).GETon a directory → 200text/html(04-webdav/21). Differs: Rostdav answers 405.MOVEwithOverwrite: Fonto an existing target → 412 (04-webdav/24). ADestinationwith a foreign host is accepted (path only is used, 201) (04-webdav/25). Differs: Rostdav answers 502.- URL user ≠ authenticated user → 404 "Principal with name … not found" (
04-webdav/30). Differs: Rostdav answers 403. ..in the path → 404 (04-webdav/31).- Legacy
/remote.php/webdav/answers PROPFIND with 207 (04-webdav/33).SEARCHprobe with an empty request → 400 (04-webdav/34). - Observed oddity:
PUTwith an invalidOC-Checksumanswered 423 "is locked" (04-webdav/15). Cause unknown; do not copy.
Chunked upload (v2)
MKCOL /remote.php/dav/uploads/{user}/{id}(headersDestination,OC-Total-Length) → 201; chunksPUT …/{id}/{00001}→ 201;MOVE …/{id}/.filewithDestination,OC-Total-Length,X-OC-MTime→ 201 and the assembled file is readable; the session folder is gone afterwards (404) (05-chunked-upload/01–07).- Total length mismatch on assembly → 400 Sabre
BadRequest"Chunks on server do not sum up to 50 but to 10 bytes" (05-chunked-upload/10).DELETEof a session → 204 (11).
Other endpoints
core/preview→ 404 without body; avatar → 200 PNG (06-misc/01,02).notifications,activity,dashboardanswer 200,user_status404 or 200 depending on app state (06-misc/03–06). Rostdav must not advertise these in the capabilities.
Sign-out
DELETE /ocs/v2.php/core/apppassword→ 200; the revoked app password is rejected afterwards with 401 on WebDAV and OCS (07-logout/01–03).
Known endpoints
Initial list from section 3.2 of the instructions. None of this is confirmed until a source is entered.
| Endpoint | Purpose | Recorded (server) | Source |
|---|---|---|---|
GET /status.php | Server discovery | yes | 01-discovery/01-status |
POST /index.php/login/v2, POST /login/v2/poll | Login Flow v2 | yes | 02-login-flow-v2/ |
GET /ocs/v2.php/cloud/capabilities | Capabilities | yes | 03-ocs/01 |
GET /ocs/v2.php/cloud/user | User info | yes | 03-ocs/04 |
/remote.php/dav/files/{userId}/ | Files | yes | 04-webdav/ |
/remote.php/webdav/ | Legacy path | yes (answers) | 04-webdav/33; whether the app still uses it: open |
/remote.php/dav/uploads/{userId}/{transferId}/ | Chunked Upload | yes | 05-chunked-upload/ |
Implemented in M4 and known gaps
status.php,capabilities(onlyversionandcore.{pollinterval, webdav-root}),cloud/user(subset of the reference fields; tests fail on any field the reference does not have),DELETE core/apppassword, Login Flow v2 start/poll/page. Contract tests compare against the fixtures above.- Not recorded, therefore chosen by us: the body of the 429 response (OCS failure code 429,
Retry-Afterset), the v1 variant of the 401 body (same as v2), the 400 answer for sign-out with the account password. - The reference redirects
/login/v2/flow/{token}(303) to a Vue login page and needs a second step to grant; Rostdav serves one form (allow/deny) directly. Whether the app cares is open until M6. - Capabilities deliberately omit
files,dav(chunking), sharing, notifications, etc. until implemented (M5+). Whether the Android app starts withoutfiles.*capabilities is open until M6. /remote.php/webdav/is served (maps to the signed-in user) becausecore.webdav-rootpoints to it.
Implemented in M5 and known gaps
Values and sets come from the fixtures; crates/rostdav/tests/nextcloud_dav.rs compares them.
- Properties:
oc:id(8-digit file ID + instance ID, e.g.00000042ocabc123def4),oc:fileid,oc:size(folders: sum below),oc:permissions(RGDNVCKfolders,RGDNVWfiles, owner only),oc:favorite(0/1, per user, see Favorites),oc:owner-id,oc:owner-display-name,oc:share-types(empty),oc:checksums(files with a client checksum),oc:downloadURL(files, empty),nc:has-preview(false: no previews exist),nc:mount-type(empty),nc:creation_time(0),nc:upload_time(files),d:quota-used-bytes/d:quota-available-bytes(folders only,-3= unlimited). Unknown properties,nc:is-encryptedand folder-only/file-only mismatches answer 404 in their ownpropstat.allpropreturns the reference's sets (04-webdav/03: folders get quota properties, no content length). - Headers:
PUTreturnsOC-FileId,ETag,OC-ETag(andX-OC-MTime: acceptedwhen sent);MKCOLandCOPYreturnOC-FileId;MOVEreturnsOC-ETag,ETag,OC-FileId;GET/HEADaddOC-ETag. X-OC-MTimesets the modification time.OC-Checksumis stored verbatim and reported; like the reference (08-m5-probes/03–09) it is not verified, any algorithm and even wrong digests are accepted. Values that are notALGO:hexare ignored.- Errors: Sabre XML bodies (
<d:error><s:exception>Sabre\DAV\Exception\…</s:exception><s:message>…</s:message></d:error>). Exception classes seen in the fixtures:NotFound,Conflict,MethodNotAllowed,PreconditionFailed,BadRequest,NotAuthenticated. Not recorded and taken from Sabre's class names:Forbidden,InsufficientStorage(507),PayloadTooLarge,Locked,TooManyRequests,ServiceUnavailable. - Aligned with the reference (differences found in M3, fixed here):
PUTinto a missing parent → 404 (MKCOLstays 409); another user's URL space → 404; the host ofDestinationis ignored;.part/.filepartnames,\, control characters and over-long names → 400 with the reference messages. "Control characters" are the characters 0 to 31 only, as in Nextcloud'sFilenameValidator.php("Characters in the range [0-31] are always forbidden"; checked in the source, not against the reference server): DEL and U+0080 to U+009F are allowed, because real Nextcloud data directories contain such names (found when importing one, 0.5.1). - Chunked upload v2 (
/remote.php/dav/uploads/<user>/<id>):MKCOL201 (405 if it exists),PUT <chunk>201/204,PROPFINDlists.fileand chunks,MOVE .filewithDestination,Overwrite,OC-Total-Length,X-OC-MTimeassembles (201, or 204 when replacing),DELETE204, other users' sessions are 404, listing all sessions is 405. Chunks are joined in natural name order (numbers by value). Capabilities advertisedav.chunking = "1.0",files.chunked_upload.{max_size, max_parallel_count}andfiles.forbidden_filename_extensions. Sessions older than 24 h are removed by the garbage collector. - Previews and avatars:
core/previewandavataranswer 404 without body. The reference serves an avatar image; Rostdav has none. How the app reacts is open until M6.
Known differences that remain, all open for M6:
| Reference | Rostdav | Why |
|---|---|---|
GET on a folder → 200 HTML | 405 | no web UI yet |
PROPFIND Depth: infinity → 207, no entries | 403 propfind-finite-depth | RFC 4918 allows refusing; avoids unbounded work |
OPTIONS: DAV: 1, 3, extended-mkcol, …; Allow incl. PROPPATCH, REPORT, LOCK, UNLOCK | DAV: 1 | only advertise what is implemented |
Failed assembly with Overwrite: F (412) destroys the session | session is kept | safer for retries |
Chunk assembly with a missing Destination → 500 | 400 | reference answer is an internal error |
X-OC-MTime garbage → 500 | 400 | same |
.htaccess upload → 500 "Invalid path" | allowed | not a PHP server |
nc:has-preview true for text files | false | no previews |
nc:creation_time 0 | 0 | same, upload time is separate |
PROPPATCH, REPORT, SEARCH, LOCK | 405 | not implemented |
| Chunk file names: anything | [A-Za-z0-9_-]{1,32} | names must never form paths |
Implemented in M8: versions
Source: tests/fixtures/nextcloud/09-versions/ (recorded with scripts/record_nextcloud_extra.py versions), compared by crates/rostdav/tests/versions.rs.
- Policy: an overwrite (PUT, chunked assembly, import) keeps the previous content as a version unless it is identical (same checksum). Versions count toward the user's quota (documented decision,
docs/instructions.md2.4), also inquota-used-bytesof the root folder. Limits:[versioning](max_versionsper file,max_age_days,max_quota_share_percent), applied on write and by the background cleanup. Copies get no history; a move keeps it; deleting a file deletes its versions (trash comes in M10). - Capabilities:
files.versioningandfiles.version_deletionare advertised only while versioning is enabled.files.version_labelingis not:PROPPATCHis not implemented. - Endpoint
/remote.php/dav/versions/<user>/: collection withversions/andrestore/;versions/<file id>/lists one entry per archived version and the live content, ascending by name; the name of a version is the modification time of its content (moved to the next free second if two versions of a file share a time). Entries havegetlastmodified,getcontentlength,getcontenttype,getetag(the plain name, unquoted, as in the reference),resourcetypeandnc:version-author;nc:version-labelandoc:fileidanswer 404.GET/HEADreturn the content withETag/OC-ETag= name and support ranges.DELETEremoves an archived version (404 if it does not exist).MOVEto.../restore/targetrestores: the version becomes the live content (with its modification time) and the replaced content is archived. File IDs are the plain numeric IDs (oc:fileid), not the paddedoc:id. - Errors: another user's space or file, unknown file or version, non-numeric names → 404;
PUT/MKCOLbelowversions/→ 403.
| Reference | Rostdav | Why |
|---|---|---|
GET of the live entry → 404 (but HEAD → 200) | 200 with the content | looks like an oversight; harmless |
DELETE of the live entry → 204 and unclear effect | 403 | the live file must not vanish this way |
MOVE to any destination other than restore/target → 201 and the version is moved out as a file | 400 | not needed; avoids surprising data movement |
MOVE without Destination → 500 (TypeError) | 400 | |
Depth: infinity → 207 | 403 | as for files |
PROPPATCH of nc:version-label | 405 | not implemented |
| Versions of a deleted file stay reachable through the trash | gone with the file | M10 |
| Identical re-upload still creates a version (not recorded) | no new version | saves space |
Implemented in M9: shares
Source: tests/fixtures/nextcloud/10-shares/ (scripts/record_nextcloud_extra.py shares), compared by crates/rostdav/tests/sharing.rs; design and decisions in ADR 0004.
- OCS Sharing API (
/ocs/v{1,2}.php/apps/files_sharing/api/v1/):POST/GET shares(by path,subfiles,shared_with_me),GET/PUT/DELETE shares/{id},GET shares/pending(always empty),GET sharees. Status codes and messages are the reference's ("Cannot share with yourself", "Wrong path, file/folder does not exist", ...). The share object is a subset of the reference's fields (storage,storage_idare left out).GET shares/{id}answers with a one-element list, like the reference. - Share types: user (0) and public link (3). No groups, federation, mail, circles or talk.
- Permissions: bits read 1, update 2, create 4, delete 8. Bit 16 (share) is accepted and ignored, so
31is stored and reported as15; resharing is refused with 403 "Resharing is not allowed". Files keep only read/update. A link may be create-only (4, "file drop"); user shares need read. - Mount points: the recipient's root lists each share under the shared item's name (a free name:
Docs (2)if taken). Properties as in the reference:oc:permissionsstarts withS,nc:mount-type=shared,nc:is-mount-root=trueon the root only,oc:owner-id= owner,oc:share-typeson the mount root and on the owner's shared node. The recipient may rename a mount (MOVEinside the root) and leave it (DELETE, which ends the share for the owner too). The root ETag of a user with mounts is derived from all mounted ETags, so clients notice changes inside shares. - Writes need the right bit: create (4) for new files/folders, update (2) to overwrite, delete (8) to delete; renaming needs delete + create. Denied writes answer 403; things you may not see answer 404. Quota and versions of writes into a share belong to the owner. Copies may cross trees (the copy belongs to the destination owner); moves across trees are refused (409).
- Public links:
/public.php/webdav/withAuthorization: Basic <token>:<password>(password empty if none); the shared node is the root (a file link serves the file as/). Tokens are 24 random characters; passwords are Argon2id hashes and never shown ("password": "protected"); failures count per address and per link (429 after 10). Expired or deleted links answer 401. - Not for links/recipients: versions, chunked uploads (sessions are the signed-in user's), OCS user data, share management. A link cannot reach any user namespace and a user cannot use the public one.
- Events:
share.createdandshare.revoked(owner or recipient) go to the event log.
| Reference | Rostdav | Why |
|---|---|---|
Resharing (R letter, share bit) | refused; the R letter appears only on the owner's own items | ADR 0004 |
Read-only link shows RG | G | no resharing |
password of a link share: the hash | "protected" | never expose hashes |
Link web page /s/<token> | 404 until M11 | scope |
| Pending shares | always empty | shares are active immediately |
| Groups, federation, mail shares | not offered | scope |
| Duplicate user share: returns the existing share | same | |
| Create-only link reveals nothing about existing names in the reference | a PUT onto an existing name is 403 instead of 201 (reveals existence) | accepted, documented |
Implemented in M10: trash
Source: tests/fixtures/nextcloud/11-trash/ (scripts/record_nextcloud_extra.py trash), compared by crates/rostdav/tests/trash.rs; design in ADR 0005.
- Behavior:
DELETEof a file or folder moves it to the owner's trash (when[trash] enabled, default). Its space (and that of its versions) keeps counting toward the quota until the item is purged. Items are purged byDELETE, by emptying, or by[trash] retention_days(30). Restoring brings back the item under its old parent (else the root), with the same file ID, its versions and its shares; a taken name becomesname (restored).ext. - Capabilities:
files.undeleteandfiles.delete_from_trashwhile the trash is enabled. - Endpoint
/remote.php/dav/trashbin/<user>/: membersrestore/andtrash/; each item istrash/<name>.d<deletion time>(folders end in/) withnc:trashbin-filename,-original-location(no leading slash),-deletion-time,-title,-deleted-by-id,-deleted-by-display-name,getlastmodified(the deletion time),getcontentlength/getcontenttype(files),getetag(empty for files, the deletion time for folders),oc:fileidandoc:id(the plain number),oc:size,oc:permissions=GD,nc:has-preview=false.GET/HEADwith ranges read files; folders can be browsed (PROPFIND,GETbelow them).MOVE trash/<item>torestore/<anything>restores (201),DELETE trash/<item>purges (204),DELETE trash/empties (204).PUT/MKCOL/COPY→ 403, other users' trash → 404. - Shares: deletions by recipients or through editable links go to the owner's trash (recorded as
deleted-by); the recipient's trash is not filled. A share on a trashed folder (or of something inside it) is inactive while it is in the trash and active again after the restore.
| Reference | Rostdav | Why |
|---|---|---|
| Deleted by a recipient: item appears in the recipient's trash and in the owner's | only the owner's | recipients cannot restore things they do not own |
MOVE of an item to any destination → the item is moved there | 400 unless …/restore/* | avoids surprising data movement |
MOVE without Destination → 500 (TypeError) | 400 | |
Depth: infinity → 207 | 403 | as elsewhere |
nc:has-preview true for text files | false | no previews |
ETag header of a trashed file empty | not sent | |
| Name conflict on restore: not recorded | name (restored).ext | chosen |
MOVE/COPY with Overwrite: T over an existing file | the replaced file is deleted for good | not recorded for the reference |
Implemented in M12: two-factor authentication
Source: requirements (Nextcloud behaviour as described in docs/instructions.md), ADR 0007; not recorded from the reference server or the real app.
| Topic | Behaviour here | Status |
|---|---|---|
| App passwords and Login Flow polling | Unchanged; they bypass 2FA | by design, as in Nextcloud |
| Account password via Basic on WebDAV/OCS with 2FA on | Rejected (401) | tested here; the real app's reaction is not tested |
| Capabilities | Nothing added, so the app starts no 2FA flow of its own | as intended |
| Login Flow page with 2FA | Second step on the page (code or recovery code) before the app password is granted | tested here; the real app was not used |
To check in M6 (emulator): connect the Nextcloud app to an account with 2FA on (through the Login Flow page) and use it afterwards.
M6: the real Android app (Nextcloud Android 35.0.1, Android 15 emulator, 2026-10-22)
Server address entered as http://10.0.2.2:8081. The app was driven through adb; every row below was seen, not assumed.
| Step | Finding | Fix |
|---|---|---|
| Server check | The app sends HEAD /remote.php/dav and says "Server not found" unless it gets 401 (not signed in). Rostdav answered 404 | /remote.php/dav[/]: 401 for strangers; GET/HEAD 200 text, OPTIONS 200, PROPFIND 207 for signed-in users (as the reference) |
| Right after the sign-in | HEAD /remote.php/dav/files/<user>/ got 405; the app then restarted the whole login (endless loop) | GET/HEAD on a collection answer 200 with the one-line text, as the reference |
| Login Flow in the app's browser tab | The page renders and works; the app polls after the tab is closed | none; the client name shown is the whole user agent (Mozilla/5.0 (Android) Nextcloud-android/35.0.1) |
| After the sign-in | File list (PROPFIND), capabilities, user info work; avatar, notifications and recommendations answer 404 and the app shrugs them off | none |
| Connectivity probe | GET /index.php/204 answered 404: the app saw a captive portal, cached that and created folders offline without any MKCOL. Reference: 204 without authentication | route added (tests/signin.rs); the app caches the verdict, restart it after a server fix |
| Create folder | MKCOL 201 | none |
| Upload 1 KB | PUT 201, hash identical | none |
| Upload 30 MB | chunked v2: MKCOL uploads/…, PUT …/000001, MOVE …/.file 201, hash identical | none |
| Download 30 MB | GET 200, hash identical on the device | none |
| Rename | MOVE 201 | none |
| Delete | DELETE 204, file appears in trashbin/<user>/trash | none |
| Favorite | PROPPATCH on the file answered 405 (oc:favorite was always 0, the Favorites tab needs REPORT oc:filter-files) | implemented, see Favorites below; seen with the app: PROPPATCH 207 on the folder, star and Favorites tab show it (the app did not send a REPORT in this run) |
| Capabilities | The app's parser throws No value for bigfilechunking and then stores no capabilities at all (so undelete, versioning and sharing options stay off). Reference sends files.bigfilechunking: true | files.bigfilechunking: false added (old chunking is not implemented, only v2); test in tests/signin.rs; capabilities parse now ("Get Capabilities completed") |
| Share with a user | Sharee search (sharees) finds bob; POST shares, GET/PUT shares/1 all 200; bob sees /Docs (permissions 15) in shared_with_me, lists it and writes from-bob.txt into it; the owner sees the new file | none |
| Trash in the app | Sidebar "Deleted files" lists the deleted file (PROPFIND trashbin/<user>/trash/, original location shown); "Restore" sends MOVE trashbin/<user>/trash/<name>.d<ts> → 201, file is back at its place. (The earlier "no entry" was the stale capabilities cache before an app restart.) | none |
| Login Flow with 2FA | Account with two-factor on: the flow page asks user and password, then "Confirm your sign-in" with the six-digit code; after the code "Device connected"; closing the tab, the app lists the account's files. The account password alone on WebDAV answers 401 (ADR 0007) | none |
| Public link | "Create link" on a folder: POST shares 200, GET shares/2 200; the returned URL http://10.0.2.2:8081/s/<token> opens the public page (200) and public.php/webdav answers 207 with the token as user; permissions 1 (read) | none |
| Offline, then reconnect | Server stopped, 2 KB file queued for upload in the app, server started again: the app retried by itself (HEAD 404, PUT 201, PROPFIND 207) within 90 s, hash identical | none |
| Versions | The file's Activities tab lists "New version was created" entries (read from versions/<user>/versions/<fileid>); "Restore file" sends MOVE …/versions/<user>/versions/<fileid>/<time> with Destination: …/versions/<user>/restore/<fileid> and Overwrite: T. Rostdav only accepted restore/target and answered 400. The reference accepts any single name below restore/ | any single name accepted (restore_accepts_the_android_apps_destination in tests/versions.rs); app restore now 201 and the old content is back. Other destinations stay 400 (the reference copies the version to the given path, a documented deviation) |
Both bugs were invisible to the contract tests because the fixtures contained neither request.
M6: the Nextcloud desktop sync client (nextcloudcmd 4.0.6, Linux, 2026-10-23)
nextcloudcmd is the sync engine of the desktop client without the GUI, so the sync logic is the real one; the GUI, the file-manager integration and virtual files were not tried. Account password sign-in (no 2FA). Two local folders against one Rostdav account, current build.
| Step | Result |
|---|---|
| First sync of a folder with a subfolder, a file with umlauts in its name, a 20 MB file | uploaded (MKCOL 201, PUT 201); contents equal |
| Second machine syncs into an empty folder | all files downloaded, 20 MB file has the same SHA-256 |
| Edit a file, rename a folder, delete a file, add a 120 MB file | changes propagate to the other machine; the 120 MB file uses chunked upload v2 (PropagateUploadFileNG), hash equal |
| The edit creates a version, the deleted file is in the trash | confirmed with PROPFIND on versions/ and trashbin/ |
| Sync again on both machines | nothing transferred, trees identical, no 5xx in the server log |
Noted, not a Rostdav defect: after every MKCOL that answers 201 the client logs "Network request error … HTTP status 201" and "An unexpected error occurred". The Nextcloud 35.0.2 reference produces the same lines for the same sync, and the folder is created.
Favorites
Source: Nextcloud 35.0.2 reference, 2026-10-23, and the Android app (M6).
PROPPATCHwithoc:favorite1/0: 207, the property in a 200propstat(as the reference). The mark is per user (tablefavorites, migration 0010) and works on every node the user can read.PROPFINDoc:favorite:1or0for the caller.REPORTon a folder of the user withoc:filter-filesand the ruleoc:favorite=1: the caller's favorites below that folder, with the requested properties. Other filter rules answer 400.- Deviations: the reference also stores arbitrary dead properties in
PROPPATCH; Rostdav answers 403 for every property exceptoc:favoriterather than pretend. TheREPORTlists favorites in the user's own tree only (not nodes received through shares).
Avatars
Profile pictures are uploaded in the web interface (Settings). The Nextcloud Android app 35.0.1 requests GET /index.php/avatar/<user>/512 (M6 server log); Rostdav answers it with the stored picture.
| Request | Reference (Nextcloud 35.0.2, recorded) | Rostdav |
|---|---|---|
GET /index.php/avatar/<user>/<size> for a user with a picture | 200 image/png, scaled to size | 200, the stored PNG or JPEG as uploaded (no scaling; the size is ignored) |
| the same for a user without a picture | 200, a generated placeholder PNG | 404; the app draws its own placeholder (the app tolerated the former 404 in M6) |
| unknown user | 404 JSON | 404 |
| without credentials | 200 (public) | 401: Basic (app password) is required |
GET /avatar/<user>/<size> | 200, same as above | same as /index.php/avatar/…; the pages use it with the browser session cookie (Basic works too) |
Any signed-in user can fetch any user's picture, as in the reference. Uploads accept PNG and JPEG only (checked by signature and image header), 16 to 1024 pixels on each side, at most 256 KiB; they are stored unchanged in the database (table avatars) and sent with X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox. Pictures that other users or clients upload through the Nextcloud API (POST /index.php/avatar/) are not supported.
Maintenance mode
An administrator switches it on in the web interface (/admin/security; stored in settings, so it survives a restart; events admin.maintenance_on / _off). Source: the reference (Nextcloud 35.0.2) with occ maintenance:mode --on.
| Request | Reference | Rostdav |
|---|---|---|
GET /status.php | 200, "maintenance":true | same |
/remote.php/…, /public.php/… | 503, application/xml, Sabre error OCA\DAV\Exception\ServerMaintenanceMode / "System is in maintenance mode." | same body |
/ocs/v1.php/… (XML), /ocs/v2.php/…?format=json | 503, OCS failure "Service unavailable" | same bodies |
/ocs/v1.php/…?format=json, /ocs/v2.php/… as XML | not recorded | assumption: v1 JSON carries the empty paging fields, v2 XML equals v1 XML |
The web interface, the sign-in pages (including Login Flow), /healthz and /readyz stay available, so an administrator can switch it off. The reference sends no Retry-After; neither do we. Not tested with the real apps: the apps pause when status.php reports maintenance, per the Nextcloud client behaviour.
Search (stage 4 of the search plan, 2026-10-24)
Recorded against Nextcloud 35.0.2 with synthetic requests (scripts/record_nextcloud_extra.py search, 90 fixtures in 12-search/); Android app (Nextcloud 35.0.1, emulator, 2026-10-24), seen with the app: typing a word in the search field and pressing Enter makes it call GET /ocs/v2.php/search/providers (twice) and …/providers/files/search (three times, paging), all answered 200; it does not send WebDAV SEARCH in this flow. The result list shows the section "In this folder" (the app's own filter) and "Files" with the server's hits (Reports, Reports/Archive/old-report.md with the subline "in Reports/Archive", Quarterly-report.txt); a tap on a hit fetches it by attributes.path (PROPFIND, GET) and opens it. The app showed a generic icon for icon-file entries without a thumbnail and its own icon by file name for .txt. The iOS app and the desktop client were not tried (UNVERIFIED), and WebDAV SEARCH has been checked against the recordings only. The contract tests replay the recorded requests against the same tree.
WebDAV SEARCH /remote.php/dav/ (d:basicsearch; only the DAV root takes it, OPTIONS there lists SEARCH, /files/<user>/ answers 405 where the reference says 501):
- Scope:
d:from/d:scope/d:href=/files/<user>/<path>;d:depthis ignored by the reference (always the whole subtree) and here. A missing path or a file: 404. - Conditions:
and,or,not,is-collection,eq,gt,gte,lt,lte,like, at most 100 operators. Searchable properties as probed one by one:displaynameandgetcontenttype(like,eq),getlastmodifiedandoc:size(eq,gt,gte,lt,lte),oc:fileid(eq),oc:favorite(eq),oc:owner-id(eq, only with the home folder as scope). Everything else: 400 "requested search property is not searchable". Dates: seconds, HTTP date or ISO 8601.likeis SQLLIKE(%,_), case-insensitive, accent-sensitive (ubungdoes not findÜbung), an empty pattern finds nothing.orderbyondisplayname(byte order),getcontenttype,getlastmodified,oc:size,oc:favorite;limit/nresults. - Only what the caller may read is ever returned (same walk as the web search; mounted shares appear under their mount name; trashed files never).
- Differences, on purpose: a missing
d:where, a limit that is no number, more than 100 operators, andoc:owner-idbelow the home folder are 400 (the reference answers 500); another user's scope is 404 (500); aDOCTYPEis refused (the reference accepts it); the content type of the request is not checked (the reference answers 501 for anything but XML types); results are capped at 1,000 (the reference's cap was not found);oc:creationdateandnc:upload_timeare not searchable; the order of equal keys is by path. - Not understood from the reference: a
gte/lterange ongetlastmodifiedthat holds the files found nothing there (12-search/19); Rostdav returns the files.
Unified search GET /ocs/v2.php/search/providers and …/providers/files/search:
- Only the provider
filesis listed (the reference lists appstore, systemtags, circles, comments, settings too; they do not exist here), with the filtersterm,since,until(the reference also announcesperson,min-size,max-size,mime,type,path,is-favorite,title-only; they are ignored). - Searches names (not the text of files). Entries:
thumbnailUrl(empty: no previews),title,subline(in <folder>),resourceUrl(<base>/f/<fileId>),icon,rounded,attributes{fileId,path}. The Android app acceptsicon-folder(the reference's) andicon-file(shown as a generic file icon) (the reference points at theming SVGs that Rostdav does not serve). - Paging:
limit1 to 200 (default 5; the reference refuses 0 and 1000; 200 works there),cursoris an offset, the answer'scursoriscursor + limit, also past the end. - Errors: no
termand no date: 400 with"No valid filters provided"indata; not signed in: 401, code 997; unknown provider: 404 (the reference answers 500); a cursor that is no number: 400 (500). TheOCS-APIRequestheader is required (the reference does not require it for the provider list). - The capabilities do not announce
search_supports_creation_time,…_upload_timeor…_last_activity: those searches are not implemented.
Not supported
| Feature | App behavior without support | Source |
|---|---|---|
| Preview images | server answers 404 (06-misc/01); app behavior: observe in M6 | |
| Notifications, activities, push | the Android app 35.0.1 tolerates the 404 answers (notifications, user status, avatar) and works normally | M6, Android 35.0.1 |
| End-to-end encryption | [observe] | |
| Talk, calendar, contacts | not planned |
Open questions
oc:idformat is now known from the server (8-digit padded file ID + instance ID,04-webdav/16); whether the app depends on it: open.- Error XML format for WebDAV errors that the app evaluates.
- Which minimum version does the app require, and how does it react to older versions?
- Behavior of the app when two-factor authentication is enabled in the login flow.
This page is generated from docs/compatibility.md in the repository.