Skip to content

Commit fdf1559

Browse files
authored
Merge pull request #10387 from nextcloud/backport/10377/stable-34.0
[stable-34.0] Improved upload chunk integrity
2 parents 0eacb09 + b5386da commit fdf1559

7 files changed

Lines changed: 192 additions & 55 deletions

File tree

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,9 +463,6 @@ public final class FilesDatabaseManager: Sendable {
463463
result.downloaded = false
464464
} else if result.isUpload {
465465
result.uploaded = false
466-
result.chunkUploadId = UUID().uuidString
467-
} else if status == .normal, metadata.isUpload {
468-
result.chunkUploadId = nil
469466
}
470467

471468
logger.debug("Updated status for item metadata.", [

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,13 @@ public extension Item {
137137
log: any FileProviderLogging
138138
) async -> (Item?, Error?) {
139139
let logger = FileProviderLogger(category: "Item", log: log)
140-
let chunkUploadId =
141-
itemTemplate.itemIdentifier.rawValue.replacingOccurrences(of: "/", with: "")
142140
let (ocId, etag, date, size, error) = await upload(
143141
fileLocatedAt: localPath,
144142
toRemotePath: remotePath,
145143
usingRemoteInterface: remoteInterface,
146144
withAccount: account,
147145
inChunksSized: forcedChunkSize,
148-
usingChunkUploadId: chunkUploadId,
146+
forItemWithIdentifier: itemTemplate.itemIdentifier.rawValue,
149147
dbManager: dbManager,
150148
creationDate: itemTemplate.creationDate as? Date,
151149
modificationDate: itemTemplate.contentModificationDate as? Date,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ public extension Item {
139139
usingRemoteInterface: remoteInterface,
140140
withAccount: account,
141141
inChunksSized: forcedChunkSize,
142-
usingChunkUploadId: metadata.chunkUploadId,
142+
forItemWithIdentifier: ocId,
143143
dbManager: dbManager,
144144
creationDate: newCreationDate,
145145
modificationDate: newContentModificationDate,

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Upload.swift

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,44 @@ import RealmSwift
99

1010
let defaultFileChunkSize = 104_857_600 // 100 MiB
1111

12+
/// The per-item prefix shared by every chunked-upload identifier for a given item, so stale chunk
13+
/// bookkeeping from earlier versions of the same item can be swept.
14+
func chunkUploadIdentifierPrefix(forItemWithIdentifier itemIdentifier: String) -> String {
15+
itemIdentifier.replacingOccurrences(of: "/", with: "") + "_"
16+
}
17+
18+
/// Derives a stable, *content-scoped* identifier for a chunked upload's server folder and its local
19+
/// `RemoteFileChunk` bookkeeping.
20+
///
21+
/// The identity is `(item, size, modificationDate)`: the same content re-uploads under the same id,
22+
/// so an interrupted transfer resumes and reuses the chunks already stored on the server. Any content
23+
/// change yields a different id, so chunks from a previous version are never spliced into a different
24+
/// one (see F3). The value is prefixed with the item id so stale sets can be swept by prefix.
25+
///
26+
/// When no modification date is available the content can't be bound to an id, so a per-attempt unique
27+
/// id is used: this forgoes resume for that upload but never risks a bad splice. In the File Provider
28+
/// model a real content change always bumps `contentModificationDate` (that is how the change was
29+
/// detected), so equal `(size, modificationDate)` reliably means identical content.
30+
func chunkUploadIdentifier(
31+
forItemWithIdentifier itemIdentifier: String, fileSize: Int64, modificationDate: Date?
32+
) -> String {
33+
let prefix = chunkUploadIdentifierPrefix(forItemWithIdentifier: itemIdentifier)
34+
35+
guard let modificationDate else {
36+
return prefix + UUID().uuidString
37+
}
38+
39+
let mtimeSeconds = Int64(modificationDate.timeIntervalSince1970.rounded())
40+
return "\(prefix)\(fileSize)_\(mtimeSeconds)"
41+
}
42+
1243
func upload(
1344
fileLocatedAt localFilePath: String,
1445
toRemotePath remotePath: String,
1546
usingRemoteInterface remoteInterface: RemoteInterface,
1647
withAccount account: Account,
1748
inChunksSized chunkSize: Int? = nil,
18-
usingChunkUploadId chunkUploadId: String? = UUID().uuidString,
49+
forItemWithIdentifier itemIdentifier: String,
1950
dbManager: FilesDatabaseManager,
2051
creationDate: Date? = nil,
2152
modificationDate: Date? = nil,
@@ -106,7 +137,9 @@ func upload(
106137
return (ocId, etag, date as? Date, size, remoteError)
107138
}
108139

109-
let chunkUploadId = chunkUploadId ?? UUID().uuidString
140+
let chunkUploadId = chunkUploadIdentifier(
141+
forItemWithIdentifier: itemIdentifier, fileSize: fileSize, modificationDate: modificationDate
142+
)
110143

111144
uploadLogger.info(
112145
"""
@@ -117,6 +150,26 @@ func upload(
117150
"""
118151
)
119152

153+
// Content-scoped resume (F3): drop any chunk bookkeeping left over from a *different* version of
154+
// this same item — a prior interrupted upload whose content has since changed derives a different
155+
// id. Keeping those rows would risk resuming against server-side chunks that belong to the old
156+
// content and splicing them into the new file. Rows under the current id (a genuine resume of
157+
// identical content) share the id and are preserved.
158+
let staleChunkPrefix = chunkUploadIdentifierPrefix(forItemWithIdentifier: itemIdentifier)
159+
do {
160+
let db = dbManager.ncDatabase()
161+
let staleChunks = db.objects(RemoteFileChunk.self).where {
162+
$0.remoteChunkStoreFolderName.starts(with: staleChunkPrefix)
163+
&& $0.remoteChunkStoreFolderName != chunkUploadId
164+
}
165+
166+
if !staleChunks.isEmpty {
167+
try db.write { db.delete(staleChunks) }
168+
}
169+
} catch {
170+
uploadLogger.error("Could not clear stale chunk bookkeeping for item.", [.error: error])
171+
}
172+
120173
let remainingChunks = dbManager
121174
.ncDatabase()
122175
.objects(RemoteFileChunk.self)

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

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -697,13 +697,31 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase {
697697

698698
func testCreateFileChunkedResumed() async throws {
699699
let chunkSize = 2
700-
let expectedChunkUploadId = UUID().uuidString // Check if illegal characters are stripped
701-
let illegalChunkUploadId = expectedChunkUploadId + "/" // Check if illegal characters are stripped
700+
let expectedChunkUploadIdBase = UUID().uuidString // Check that illegal characters are stripped.
701+
let illegalChunkUploadId = expectedChunkUploadIdBase + "/" // Check that illegal characters are stripped.
702+
703+
let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file")
704+
let tempData = Data(repeating: 1, count: chunkSize * 3)
705+
try tempData.write(to: tempUrl)
706+
707+
// New-item creation has no persisted ItemMetadata (the OS supplies the template), so the chunk
708+
// id is derived from the template's itemIdentifier plus the content's (size, modificationDate),
709+
// with illegal path characters stripped. Seed the prior interrupted attempt under exactly that
710+
// derived id so the resume path recognises identical content.
711+
let modificationDate = Date(timeIntervalSince1970: 1_700_000_000)
712+
let chunkUploadId = chunkUploadIdentifier(
713+
forItemWithIdentifier: illegalChunkUploadId,
714+
fileSize: Int64(tempData.count),
715+
modificationDate: modificationDate
716+
)
717+
XCTAssertTrue(chunkUploadId.hasPrefix(expectedChunkUploadIdBase + "_"))
718+
XCTAssertFalse(chunkUploadId.contains("/"))
719+
702720
let previousUploadedChunkNum = 1
703721
let preexistingChunk = RemoteFileChunk(
704722
fileName: String(previousUploadedChunkNum),
705723
size: Int64(chunkSize),
706-
remoteChunkStoreFolderName: expectedChunkUploadId
724+
remoteChunkStoreFolderName: chunkUploadId
707725
)
708726

709727
let db = Self.dbManager.ncDatabase()
@@ -712,37 +730,24 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase {
712730
RemoteFileChunk(
713731
fileName: String(previousUploadedChunkNum + 1),
714732
size: Int64(chunkSize),
715-
remoteChunkStoreFolderName: expectedChunkUploadId
733+
remoteChunkStoreFolderName: chunkUploadId
716734
),
717735
RemoteFileChunk(
718736
fileName: String(previousUploadedChunkNum + 2),
719737
size: Int64(chunkSize),
720-
remoteChunkStoreFolderName: expectedChunkUploadId
738+
remoteChunkStoreFolderName: chunkUploadId
721739
)
722740
])
723741
}
724742

725743
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
726-
remoteInterface.currentChunks = [expectedChunkUploadId: [preexistingChunk]]
727-
728-
// With real new item uploads we do not have an associated ItemMetadata as the template is
729-
// passed onto us by the OS. We cannot rely on the chunkUploadId property we usually use
730-
// during modified item uploads.
731-
//
732-
// We therefore can only use the system-provided item template's itemIdentifier as the
733-
// chunked upload identifier during new item creation.
734-
//
735-
// To test this situation we set the ocId of the metadata used to construct the item
736-
// template to the chunk upload id.
744+
remoteInterface.currentChunks = [chunkUploadId: [preexistingChunk]]
745+
737746
var fileItemMetadata = SendableItemMetadata(
738747
ocId: illegalChunkUploadId, fileName: "file", account: Self.account
739748
)
740-
fileItemMetadata.ocId = illegalChunkUploadId
741749
fileItemMetadata.classFile = NKTypeClassFile.document.rawValue
742-
743-
let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file")
744-
let tempData = Data(repeating: 1, count: chunkSize * 3)
745-
try tempData.write(to: tempUrl)
750+
fileItemMetadata.date = modificationDate
746751

747752
let fileItemTemplate = Item(
748753
metadata: fileItemMetadata,
@@ -776,7 +781,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase {
776781
XCTAssertEqual(remoteItem.directory, fileItemMetadata.directory)
777782
XCTAssertEqual(remoteItem.data, tempData)
778783
XCTAssertEqual(
779-
remoteInterface.completedChunkTransferSize[expectedChunkUploadId],
784+
remoteInterface.completedChunkTransferSize[chunkUploadId],
780785
Int64(tempData.count) - preexistingChunk.size
781786
)
782787

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

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,13 +1292,31 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase {
12921292

12931293
func testModifyFileContentsChunkedResumed() async throws {
12941294
let chunkSize = 2
1295-
let chunkUploadId = UUID().uuidString
1295+
let newContents = Data(repeating: 1, count: chunkSize * 3)
1296+
let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test")
1297+
try newContents.write(to: newContentsUrl)
1298+
1299+
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
1300+
1301+
let itemMetadata = remoteItem.toItemMetadata(account: Self.account)
1302+
Self.dbManager.addItemMetadata(itemMetadata)
1303+
1304+
// The chunk id is derived from (item, size, modificationDate); seed the prior interrupted
1305+
// attempt under exactly that derived id so the resume path recognises identical content.
1306+
let modificationDate = Date(timeIntervalSince1970: 1_700_000_000)
1307+
let chunkUploadId = chunkUploadIdentifier(
1308+
forItemWithIdentifier: itemMetadata.ocId,
1309+
fileSize: Int64(newContents.count),
1310+
modificationDate: modificationDate
1311+
)
1312+
12961313
let previousUploadedChunkNum = 1
12971314
let preexistingChunk = RemoteFileChunk(
12981315
fileName: String(previousUploadedChunkNum),
12991316
size: Int64(chunkSize),
13001317
remoteChunkStoreFolderName: chunkUploadId
13011318
)
1319+
remoteInterface.currentChunks = [chunkUploadId: [preexistingChunk]]
13021320

13031321
let db = Self.dbManager.ncDatabase()
13041322
try db.write {
@@ -1316,19 +1334,8 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase {
13161334
])
13171335
}
13181336

1319-
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem)
1320-
remoteInterface.currentChunks = [chunkUploadId: [preexistingChunk]]
1321-
1322-
var itemMetadata = remoteItem.toItemMetadata(account: Self.account)
1323-
itemMetadata.chunkUploadId = chunkUploadId
1324-
Self.dbManager.addItemMetadata(itemMetadata)
1325-
1326-
let newContents = Data(repeating: 1, count: chunkSize * 3)
1327-
let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test")
1328-
try newContents.write(to: newContentsUrl)
1329-
13301337
var targetItemMetadata = SendableItemMetadata(value: itemMetadata)
1331-
targetItemMetadata.date = .init()
1338+
targetItemMetadata.date = modificationDate
13321339
targetItemMetadata.size = Int64(newContents.count)
13331340

13341341
let item = Item(
@@ -1366,9 +1373,6 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase {
13661373
remoteInterface.completedChunkTransferSize[chunkUploadId],
13671374
Int64(newContents.count) - preexistingChunk.size
13681375
)
1369-
1370-
let dbItem = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: itemMetadata.ocId))
1371-
XCTAssertNil(dbItem.chunkUploadId)
13721376
}
13731377

13741378
func testModifyDoesNotPropagateIgnoredFile() async {

0 commit comments

Comments
 (0)