Skip to content

Commit d4dee2d

Browse files
authored
Merge pull request #10384 from nextcloud/backport/10369/stable-34.0
[stable-34.0] Check for file provider upload size mismatch
2 parents 3c5763c + 057907b commit d4dee2d

6 files changed

Lines changed: 165 additions & 18 deletions

File tree

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Create.swift

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,31 @@ public extension Item {
202202
"""
203203
)
204204

205-
if let expectedSize = itemTemplate.documentSize??.int64Value, size != expectedSize {
206-
logger.info(
207-
"""
208-
Created item upload reported as successful, but there are differences between
209-
the received file size (\(Int(size ?? -1)))
210-
and the original file size (\(itemTemplate.documentSize??.int64Value ?? 0))
211-
"""
212-
)
205+
// Integrity check: the size the server reports it stored must match the bytes we
206+
// uploaded. On a mismatch the transfer was torn/truncated (a dropped connection, or an app
207+
// such as Adobe InDesign/Illustrator still writing the file). Refuse to record a clean
208+
// creation: delete the partial remote object (best effort) so a retry starts clean, then
209+
// return a *transient* error so the File Provider system re-drives the create instead of
210+
// surfacing a broken file as synced.
211+
//
212+
// The error must be transient to get an automatic retry: per NSFileProviderReplicatedExtension,
213+
// resolvable NSFileProviderError codes such as `.cannotSynchronize` make the system back off
214+
// until the provider signals resolution, whereas "any other error … in NSCocoaErrorDomain" is
215+
// retried. NOTE: a create retry may arrive with `.mayAlreadyExist`, which `create()` currently
216+
// short-circuits — tracked as a separate follow-up.
217+
let localAttributes = try? FileManager.default.attributesOfItem(atPath: localPath)
218+
219+
if let localSize = localAttributes?[.size] as? Int64, let uploadedSize = size, uploadedSize != localSize {
220+
logger.error("Upload integrity check failed for created item: server stored \(uploadedSize) bytes but the local file is \(localSize) bytes. Removing the partial remote object and returning a transient error so the system retries.", [.name: itemTemplate.filename])
221+
let (_, _, deleteError) = await remoteInterface.delete(remotePath: remotePath, account: account, options: .init(), taskHandler: { _ in })
222+
223+
if deleteError != .success {
224+
logger.error("Could not remove partial remote object after integrity failure.", [.name: itemTemplate.filename, .error: deleteError])
225+
}
226+
227+
return (nil, NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError, userInfo: [
228+
NSLocalizedDescriptionKey: "Upload integrity check failed: server stored \(uploadedSize) of \(localSize) bytes."
229+
]))
213230
}
214231

215232
let contentType: String = if itemTemplate.contentType == .aliasFile {

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,25 @@ public extension Item {
229229
"""
230230
)
231231

232+
// Integrity check: the size the server reports it stored must match the bytes we handed it.
233+
// A mismatch means a torn/truncated transfer (a dropped connection, or an app such as Adobe
234+
// InDesign/Illustrator still writing the file). Do NOT record it as a clean upload — return a
235+
// *transient* error so the File Provider system re-drives the modification instead of
236+
// committing a broken file. See F1 in the Adobe compatibility diagnosis.
237+
//
238+
// The error must be transient to get an automatic retry: per NSFileProviderReplicatedExtension,
239+
// the resolvable NSFileProviderError codes (`.cannotSynchronize`, `.notAuthenticated`,
240+
// `.excludedFromSync`, …) make the system back off until the provider calls
241+
// `signalErrorResolved(_:)`. "Any other error … in NSCocoaErrorDomain" is treated as transient
242+
// and retried. We leave the row in its `.uploading` state so the retry settles it to `.normal`.
232243
let contentAttributes = try? FileManager.default.attributesOfItem(atPath: newContents.path)
233-
if let expectedSize = contentAttributes?[.size] as? Int64, size != expectedSize {
234-
logger.info(
235-
"""
236-
Item content modification upload reported as successful,
237-
but there are differences between the received file size (\(size ?? -1))
238-
and the original file size (\(documentSize?.int64Value ?? 0))
239-
"""
240-
)
244+
245+
if let localSize = contentAttributes?[.size] as? Int64, let uploadedSize = size, uploadedSize != localSize {
246+
logger.error("Upload integrity check failed for item: server stored \(uploadedSize) bytes but the local file is \(localSize) bytes. Returning a transient error so the system retries.", [.name: filename, .item: ocId])
247+
248+
return (nil, NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError, userInfo: [
249+
NSLocalizedDescriptionKey: "Upload integrity check failed: server stored \(uploadedSize) of \(localSize) bytes."
250+
]))
241251
}
242252

243253
var newMetadata =

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteInterface.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,14 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable {
591591
/// Use this to simulate server-side upload rejections (e.g. 404 path gone, 507 quota).
592592
public var uploadError: NKError?
593593

594+
/// When non-nil, the upload mock reports this as the stored size in its response, regardless
595+
/// of the bytes actually written to the mock tree. Use this to simulate a torn/truncated
596+
/// transfer (the server stores fewer/more bytes than the client sent) so the upload integrity
597+
/// check in the production code can be exercised. The chunked upload path delegates to this
598+
/// same `upload(...)`, so the override applies to both the single-shot and chunked paths.
599+
/// `nil` echoes the real stored size.
600+
public var uploadResponseSizeOverride: Int64?
601+
594602
/// Handler to track enumerate calls
595603
public var enumerateCallHandler: ((String, EnumerateDepth, Bool, [String], Data?, Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) -> Void)?
596604

@@ -832,7 +840,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable {
832840
item.identifier,
833841
item.versionIdentifier,
834842
responseDate,
835-
item.size,
843+
uploadResponseSizeOverride ?? item.size,
836844
nil,
837845
.success
838846
)

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/InterfaceTests/MockRemoteInterfaceTests.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,12 @@ final class MockRemoteInterfaceTests: XCTestCase {
503503
XCTAssertEqual(targetRootFile?.ocId, expectedRoot?.identifier)
504504
XCTAssertEqual(targetRootFile?.fileName, NextcloudKit.shared.nkCommonInstance.rootFileName) // NextcloudKit gives the root dir this name
505505
XCTAssertEqual(targetRootFile?.serverUrl, "https://mock.nc.com/remote.php/dav/files/testUserId") // NextcloudKit gives the root dir this url
506-
XCTAssertEqual(targetRootFile?.date, expectedRoot?.creationDate)
506+
// `toNKFile()` maps the item's `modificationDate` onto `NKFile.date`, so compare against
507+
// that — not `creationDate`. `MockRemoteItem.init` defaults `creationDate` and
508+
// `modificationDate` to two separate `Date()` calls, which are usually (but not always)
509+
// identical; comparing `date` to `creationDate` therefore passed only when the clock did
510+
// not tick between those two calls, and flaked on CI when it did.
511+
XCTAssertEqual(targetRootFile?.date, expectedRoot?.modificationDate)
507512
XCTAssertEqual(targetRootFile?.etag, expectedRoot?.versionIdentifier)
508513

509514
let resultChildren = await remoteInterface.enumerate(

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,53 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase {
124124
XCTAssertTrue(createdItem.isUploaded)
125125
}
126126

127+
/// Upload integrity guard (F1): when the server reports it stored a different number of bytes
128+
/// than the local file contains, `create` must NOT record the item as a clean upload. It
129+
/// returns a *transient* error (so the File Provider system automatically retries the create)
130+
/// and best-effort removes the partial remote object, instead of surfacing a torn file as synced.
131+
func testCreateFileFailsOnUploadSizeMismatch() async throws {
132+
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
133+
var fileItemMetadata = SendableItemMetadata(
134+
ocId: "file-id", fileName: "file", account: Self.account
135+
)
136+
fileItemMetadata.classFile = NKTypeClassFile.document.rawValue
137+
138+
let tempUrl = FileManager.default.temporaryDirectory
139+
.appendingPathComponent("integrity-mismatch-create")
140+
try Data("Hello world".utf8).write(to: tempUrl)
141+
142+
// Simulate the server storing fewer bytes than we sent (a torn transfer).
143+
remoteInterface.uploadResponseSizeOverride = 3
144+
145+
let fileItemTemplate = Item(
146+
metadata: fileItemMetadata,
147+
parentItemIdentifier: .rootContainer,
148+
account: Self.account,
149+
remoteInterface: remoteInterface,
150+
dbManager: Self.dbManager
151+
)
152+
let (createdItemMaybe, error) = await Item.create(
153+
basedOn: fileItemTemplate,
154+
contents: tempUrl,
155+
account: Self.account,
156+
remoteInterface: remoteInterface,
157+
progress: Progress(),
158+
dbManager: Self.dbManager,
159+
log: FileProviderLogMock()
160+
)
161+
162+
XCTAssertNil(createdItemMaybe)
163+
164+
// The error must be *transient* (NSCocoaErrorDomain) so the system automatically retries
165+
// the create rather than backing off until the provider signals resolution.
166+
let nsError = try XCTUnwrap(error as NSError?)
167+
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
168+
XCTAssertEqual(nsError.code, NSFileWriteUnknownError)
169+
170+
// Nothing must be recorded as a clean upload for this item.
171+
XCTAssertNil(Self.dbManager.itemMetadata(ocId: "file-id"))
172+
}
173+
127174
func testCreateFile() async throws {
128175
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
129176
var fileItemMetadata = SendableItemMetadata(

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,66 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase {
330330
XCTAssertEqual(remoteItem.data, originalRemoteData)
331331
}
332332

333+
/// Upload integrity guard (F1): when the server reports it stored a different number of bytes
334+
/// than the local file contains, the modify must NOT record the item as a clean upload. It
335+
/// returns a *transient* error (so the File Provider system automatically retries the modify)
336+
/// and leaves the row un-uploaded, instead of committing a truncated/torn file.
337+
func testModifyFileFailsOnUploadSizeMismatch() async throws {
338+
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
339+
340+
var itemMetadata = remoteItem.toItemMetadata(account: Self.account)
341+
itemMetadata.uploaded = true
342+
itemMetadata.downloaded = true
343+
Self.dbManager.addItemMetadata(itemMetadata)
344+
345+
let newContents = "Hello, New World!".data(using: .utf8)!
346+
let newContentsUrl = FileManager.default.temporaryDirectory
347+
.appendingPathComponent("integrity-mismatch-modify")
348+
try newContents.write(to: newContentsUrl)
349+
350+
// Simulate the server storing fewer bytes than we sent (a torn transfer).
351+
remoteInterface.uploadResponseSizeOverride = Int64(newContents.count - 1)
352+
353+
var targetItemMetadata = SendableItemMetadata(value: itemMetadata)
354+
targetItemMetadata.size = Int64(newContents.count)
355+
356+
let item = Item(
357+
metadata: itemMetadata,
358+
parentItemIdentifier: .rootContainer,
359+
account: Self.account,
360+
remoteInterface: remoteInterface,
361+
dbManager: Self.dbManager
362+
)
363+
let targetItem = Item(
364+
metadata: targetItemMetadata,
365+
parentItemIdentifier: .rootContainer,
366+
account: Self.account,
367+
remoteInterface: remoteInterface,
368+
dbManager: Self.dbManager
369+
)
370+
371+
let (modifiedItem, error) = await item.modify(
372+
itemTarget: targetItem,
373+
changedFields: [.contents],
374+
contents: newContentsUrl,
375+
dbManager: Self.dbManager
376+
)
377+
378+
XCTAssertNil(modifiedItem)
379+
380+
// The error must be *transient* (NSCocoaErrorDomain, outside the resolvable
381+
// NSFileProviderError set) so the system automatically retries the modify rather than
382+
// backing off until the provider signals resolution.
383+
let nsError = try XCTUnwrap(error as NSError?)
384+
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
385+
XCTAssertEqual(nsError.code, NSFileWriteUnknownError)
386+
387+
// The item must not be recorded as a clean upload; it stays pending for the retry.
388+
let dbItem = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: itemMetadata.ocId))
389+
XCTAssertFalse(dbItem.uploaded)
390+
XCTAssertNotEqual(dbItem.status, Status.normal.rawValue)
391+
}
392+
333393
/// When the server returns 404 during an upload (the parent folder was renamed
334394
/// on another client while the file was open), the extension must:
335395
/// - clear the stale lock token so the next attempt goes without an If: header

0 commit comments

Comments
 (0)