Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 78 additions & 8 deletions Sources/NextcloudKit/NextcloudKit+Upload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,16 +439,86 @@ public extension NextcloudKit {

try Task.checkCancellation()

// Read back the final file to return NKFile
let readRes = await readFileOrFolderAsync(serverUrlFileName: serverUrlFileName,
depth: "0",
account: account,
options: options)
// Prefer the assembled file's identity straight from the MOVE response headers.
// On a successful chunk-assembly MOVE the server returns OC-FileID (and OC-ETag),
// exactly as it does on MKCOL/PUT — the reference desktop client reads these off the
// same reply and treats them as required. Using them here avoids a second, fragile
// PROPFIND read-back whose failure (server-side finalization lag, a proxy 5xx, or a
// short timeout) would otherwise throw errorChunkMoveFile even though the file
// assembled correctly, losing the ocId and failing an upload whose bytes already landed.
if let file = assembledFile(fromMoveResponseHeaders: moveRes.responseData?.response?.allHeaderFields,
account: account,
fileName: destinationFileName ?? fileName,
serverUrl: serverUrl,
size: totalFileSize,
fallbackDate: date) {
return (account, file)
}

guard readRes.error == .success, let file = readRes.files?.first else {
throw NKError.errorChunkMoveFile
// Fallback: the MOVE reply carried no OC-FileID (an older server, a proxy that strips
// it, or a 202 async assembly still finishing). Read the assembled file back — but with
// its OWN timeout and a bounded retry, so a transient PROPFIND failure or brief
// post-assembly visibility lag no longer fails an upload that already succeeded. Only
// after the retries are exhausted do we surface errorChunkMoveFile.
let readbackOptions = NKRequestOptions(timeout: 120, queue: options.queue)
let readbackBackoff: [UInt64] = [0, 1_000_000_000, 3_000_000_000] // attempt after 0s, 1s, 3s

for backoff in readbackBackoff {
if backoff > 0 {
try? await Task.sleep(nanoseconds: backoff)
}
try Task.checkCancellation()

let readRes = await readFileOrFolderAsync(serverUrlFileName: serverUrlFileName,
depth: "0",
account: account,
options: readbackOptions)
if readRes.error == .success, let file = readRes.files?.first {
return (account, file)
}
}

return (account, file)
throw NKError.errorChunkMoveFile
}

/// Builds the assembled file's `NKFile` from a chunk-assembly MOVE response's headers.
///
/// A Nextcloud server returns `OC-FileID` (and `OC-ETag`) on a successful assembly MOVE,
/// mirroring what it returns on MKCOL/PUT — the reference desktop client reads these off the
/// same reply. Returns `nil` when no `OC-FileID` is present (an older server, a proxy that
/// strips it, or a `202` async assembly still in progress), signalling the caller to fall
/// back to a PROPFIND read-back.
///
/// - Parameters:
/// - headers: The MOVE response's `allHeaderFields`, if any.
/// - account: The account identifier to stamp onto the returned file.
/// - fileName: The assembled file's name (the MOVE destination's leaf).
/// - serverUrl: The server URL of the assembled file's parent directory.
/// - size: The assembled file's size in bytes (the known local total).
/// - fallbackDate: Date to use when the response carries no usable `Date` header.
/// - Returns: An `NKFile` populated from the headers, or `nil` if `OC-FileID` is absent.
func assembledFile(fromMoveResponseHeaders headers: [AnyHashable: Any]?,
account: String,
fileName: String,
serverUrl: String,
size: Int64,
fallbackDate: Date?) -> NKFile? {
guard let ocId = nkCommonInstance.findHeader("oc-fileid", allHeaderFields: headers) else {
return nil
}
let etag = nkCommonInstance.normalizedETag(nkCommonInstance.findHeader("oc-etag", allHeaderFields: headers)
?? nkCommonInstance.findHeader("etag", allHeaderFields: headers)) ?? ""
var date = fallbackDate ?? Date()
if let dateString = nkCommonInstance.findHeader("date", allHeaderFields: headers),
let headerDate = dateString.parsedDate(using: "EEE, dd MMM y HH:mm:ss zzz") {
date = headerDate
}
return NKFile(account: account,
date: date,
etag: etag,
fileName: fileName,
ocId: ocId,
size: size,
serverUrl: serverUrl)
}
}
6 changes: 5 additions & 1 deletion Sources/NextcloudKit/NextcloudKit+WebDAV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,13 @@ public extension NextcloudKit {

do {
try urlRequest = URLRequest(url: url, method: method, headers: headers)
// Apply the caller's timeout on every path, not only when a custom requestBody is
// supplied. The default-body PROPFIND (requestBody == nil) previously fell back to
// the URLSession default timeout, silently ignoring options.timeout — every other
// WebDAV method here sets timeoutInterval unconditionally.
urlRequest.timeoutInterval = options.timeout
if let requestBody {
urlRequest.httpBody = requestBody
urlRequest.timeoutInterval = options.timeout
} else {
urlRequest.httpBody = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).getRequestBodyFile(createProperties: options.createProperties, removeProperties: options.removeProperties).data(using: .utf8)
}
Expand Down
107 changes: 107 additions & 0 deletions Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-License-Identifier: GPL-3.0-or-later

import XCTest
@testable import NextcloudKit

/// Unit tests for deriving the assembled file's `NKFile` from a chunk-assembly MOVE response's
/// headers — the primary path that lets `uploadChunkAsync` skip the fragile PROPFIND read-back
/// (whose failure otherwise surfaced as `errorChunkMoveFile`, code -9997).
final class ChunkedUploadAssemblyTests: XCTestCase {
private func makeKit() -> NextcloudKit {
#if swift(<6.0)
return NextcloudKit.shared
#else
return NextcloudKit()
#endif
}

func test_assembledFile_withOCFileId_derivesNKFileFromHeaders() {
let kit = makeKit()
let headers: [AnyHashable: Any] = [
"OC-FileID": "00000123oc9wxyzinstance",
"OC-ETag": "\"abc123\"",
"Date": "Wed, 07 Jun 2026 09:04:51 GMT"
]

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "user https://cloud.example.com",
fileName: "1.1Gb.mp4",
serverUrl: "https://cloud.example.com/remote.php/dav/files/user",
size: 1_181_116_006,
fallbackDate: nil)

XCTAssertNotNil(file, "OC-FileID present should yield an NKFile, not nil")
XCTAssertEqual(file?.ocId, "00000123oc9wxyzinstance")
XCTAssertEqual(file?.etag, "abc123", "normalizedETag should strip the surrounding quotes")
XCTAssertEqual(file?.size, 1_181_116_006)
XCTAssertEqual(file?.fileName, "1.1Gb.mp4")
XCTAssertEqual(file?.account, "user https://cloud.example.com")
}

func test_assembledFile_isCaseInsensitiveOnHeaderNames() {
let kit = makeKit()
// Lower-cased header keys must resolve the same as canonical casing.
let headers: [AnyHashable: Any] = ["oc-fileid": "fid", "oc-etag": "\"e\""]

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: nil)

XCTAssertEqual(file?.ocId, "fid")
XCTAssertEqual(file?.etag, "e")
}

func test_assembledFile_prefersOCETagOverPlainETag() {
let kit = makeKit()
let headers: [AnyHashable: Any] = [
"OC-FileID": "fid",
"OC-ETag": "\"oc-etag-value\"",
"ETag": "\"plain-etag-value\""
]

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: nil)

XCTAssertEqual(file?.etag, "oc-etag-value", "OC-ETag should win over the standard ETag")
}

func test_assembledFile_fallsBackToPlainETagWhenNoOCETag() {
let kit = makeKit()
let headers: [AnyHashable: Any] = ["OC-FileID": "fid", "ETag": "\"plain\""]

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: nil)

XCTAssertEqual(file?.etag, "plain")
}

func test_assembledFile_withoutOCFileId_returnsNilToTriggerReadbackFallback() {
let kit = makeKit()
let headers: [AnyHashable: Any] = ["ETag": "\"x\""] // no OC-FileID (e.g. 202 async assembly)

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: nil)

XCTAssertNil(file, "Absent OC-FileID must return nil so the caller falls back to a read-back")
}

func test_assembledFile_withNilHeaders_returnsNil() {
let kit = makeKit()

let file = kit.assembledFile(fromMoveResponseHeaders: nil,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: nil)

XCTAssertNil(file)
}

func test_assembledFile_usesFallbackDateWhenNoDateHeader() {
let kit = makeKit()
let fallback = Date(timeIntervalSince1970: 1_000_000)
let headers: [AnyHashable: Any] = ["OC-FileID": "fid"] // no Date header

let file = kit.assembledFile(fromMoveResponseHeaders: headers,
account: "a", fileName: "f", serverUrl: "s", size: 1, fallbackDate: fallback)

XCTAssertEqual(file?.date, fallback)
}
}
Loading