From 12cff5af7b5cb2de14ae22cb284ad51db4a76607 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Fri, 17 Jul 2026 15:08:48 +0200 Subject: [PATCH] feat(upload): Support If-Match precondition on chunked assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `ifMatch` parameter to `uploadChunkAsync`. When set, the ETag precondition is applied ONLY to the final assembly MOVE that materializes the destination file — never to the chunk PUTs, which target brand-new chunk resources and would spuriously fail with 412. It is also cleared before the post-assembly PROPFIND readback, whose target carries a fresh ETag after a successful MOVE. This lets clients perform optimistic-concurrency conflict detection for chunked (large-file) uploads: if the destination changed since the base version the client edited, the server rejects the assembly with 412 Precondition Failed instead of silently overwriting the newer copy. Single-request PUT uploads can already carry `If-Match` via `NKRequestOptions.customHeader`; this closes the gap for the chunked path, where the shared header bag would otherwise leak the precondition onto the chunk PUTs. Signed-off-by: Iva Horn Co-Authored-By: Claude Opus 4.8 --- Sources/NextcloudKit/NextcloudKit+Upload.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/NextcloudKit/NextcloudKit+Upload.swift b/Sources/NextcloudKit/NextcloudKit+Upload.swift index 6e6a072f..2609f83e 100644 --- a/Sources/NextcloudKit/NextcloudKit+Upload.swift +++ b/Sources/NextcloudKit/NextcloudKit+Upload.swift @@ -188,6 +188,7 @@ public extension NextcloudKit { /// - chunkSize: Desired chunk size in bytes. /// - account: Account identifier. /// - options: Request options (headers, timeout, etc.). + /// - ifMatch: Optional ETag precondition applied **only** to the final assembly `MOVE` (not the chunk uploads). When set, the server rejects the assembly with `412 Precondition Failed` if the destination changed since this ETag, enabling optimistic-concurrency conflict detection for chunked uploads. /// - chunkProgressHandler: Reports per-chunk preparation progress as `(totalChunks, currentIndex)`. /// - uploadStart: Called once when upload of chunks begins, with the final list of chunks. /// - uploadTaskHandler: Exposes the low-level `URLSessionTask` for each chunk upload. @@ -210,6 +211,7 @@ public extension NextcloudKit { chunkSize: Int, account: String, options: NKRequestOptions = NKRequestOptions(), + ifMatch: String? = nil, chunkProgressHandler: @escaping (_ total: Int, _ counter: Int) -> Void = { _, _ in }, uploadStart: @escaping (_ filesChunk: [(fileName: String, size: Int64)]) -> Void = { _ in }, uploadTaskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, @@ -410,6 +412,14 @@ public extension NextcloudKit { let assembleTimeMax: Double = 30 * 60 // 30 minutes options.timeout = max(assembleTimeMin, min(assembleTimePerGB * assembleSizeInGB, assembleTimeMax)) + // Optimistic concurrency: guard the assembly against a concurrent change to + // the destination. The precondition must ride ONLY on the MOVE that + // materializes the final file — never on the chunk PUTs above, which target + // brand-new chunk resources and would spuriously fail with 412. + if let ifMatch { + options.customHeader?["If-Match"] = ifMatch + } + assembling() let moveRes = await moveFileOrFolderAsync(serverUrlFileNameSource: serverUrlFileNameSource, @@ -418,6 +428,11 @@ public extension NextcloudKit { account: account, options: options) + // Don't let the precondition leak onto the post-assembly PROPFIND readback: + // after a successful MOVE the destination carries a fresh etag, which would + // fail an If-Match against the pre-assembly value. + options.customHeader?["If-Match"] = nil + guard moveRes.error == .success else { return (account, nil) }