Skip to content
Closed
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
4 changes: 4 additions & 0 deletions Cryptomator.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@
B3C397FE2EB10FC0001280AC /* ShareVaultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C397FC2EB10FC0001280AC /* ShareVaultViewController.swift */; };
B3C398002EB110F9001280AC /* ShareVaultViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C397FF2EB110F9001280AC /* ShareVaultViewModel.swift */; };
B3D19A442CB937C700CD18A5 /* FileProviderCoordinatorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D19A432CB937BF00CD18A5 /* FileProviderCoordinatorError.swift */; };
CAB100002600000000000003 /* DatabaseHelperMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB100002600000000000004 /* DatabaseHelperMigrationTests.swift */; };
FB6962AFC1C60F6728A7850E /* FileProviderAdapterRecoverUploadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153B84C203850D7414DE2A66 /* FileProviderAdapterRecoverUploadsTests.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -1089,6 +1090,7 @@
B3C397FC2EB10FC0001280AC /* ShareVaultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareVaultViewController.swift; sourceTree = "<group>"; };
B3C397FF2EB110F9001280AC /* ShareVaultViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareVaultViewModel.swift; sourceTree = "<group>"; };
B3D19A432CB937BF00CD18A5 /* FileProviderCoordinatorError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderCoordinatorError.swift; sourceTree = "<group>"; };
CAB100002600000000000004 /* DatabaseHelperMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseHelperMigrationTests.swift; sourceTree = "<group>"; };
D4BFCEFE82DA5BB518E9DA8B /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Intents.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -1938,6 +1940,7 @@
isa = PBXGroup;
children = (
4A4A3863253F2B1900EE3828 /* CachedFileManagerTests.swift */,
CAB100002600000000000004 /* DatabaseHelperMigrationTests.swift */,
4ABC08D6250D1EB600E3CEDC /* DeletionTaskManagerTests.swift */,
4A231B83271EFC6100987492 /* DownloadTaskManagerTests.swift */,
4A49FABD271ECDE80069A0CC /* ItemEnumerationTaskManagerTests.swift */,
Expand Down Expand Up @@ -2732,6 +2735,7 @@
4A9C8E0127A0104E000063E4 /* EnumerationSignalingMock.swift in Sources */,
4AEECD2F279EA27300C6E2B5 /* FileProviderAdapterSetTagDataTests.swift in Sources */,
4AEECD3D279EB4B200C6E2B5 /* FileProviderAdapterProvidingMock.swift in Sources */,
CAB100002600000000000003 /* DatabaseHelperMigrationTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
54 changes: 54 additions & 0 deletions CryptomatorFileProvider/DB/DatabaseHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Copyright © 2020 Skymatic GmbH. All rights reserved.
//

import CocoaLumberjackSwift
import CryptomatorCloudAccessCore
import FileProvider
import Foundation
Expand Down Expand Up @@ -209,9 +210,62 @@ public struct DatabaseHelper: DatabaseHelping {
)
""")
}
migrator.registerMigration("v5", foreignKeyChecks: .immediate) { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}
try migrator.migrate(dbWriter)
}

/**
Repairs `cloudPath` values that became stale because earlier versions of `moveItemLocally` updated only the moved folder's row.

Walks the tree breadth-first from the root via `parentID` and rewrites any `cloudPath` whose canonical value (derived from the parent's path plus the row's `name`) differs from the stored value. Rows that are not reachable from the root (orphans, disconnected cycles) are intentionally left untouched.

If a row's canonical path is already occupied by another row (e.g. a duplicate created after the stale-descendant bug), the conflicting row is left at its stale path and the conflict is logged. When that happens, the BFS does not descend into the conflicted folder's children — descending would rewrite descendants to a `/canonical/…` prefix whose `/canonical` parent stayed stale, splitting the subtree.

Also creates an index on `itemMetadata.parentID` so future `parentID`-based lookups — including the runtime descendant rewrite — do not require a table scan.
*/
static func repairCloudPathsMigration(_ db: Database) throws {
try db.execute(sql: "CREATE INDEX IF NOT EXISTS itemMetadata_parentID ON itemMetadata(parentID)")

var queue: [(parentID: Int64, parentPath: CloudPath)] = [(NSFileProviderItemIdentifier.rootContainerDatabaseValue, CloudPath("/"))]
var head = 0
var visitedCount = 1
while head < queue.count {
let (parentID, parentPath) = queue[head]
head += 1
let rows = try Row.fetchAll(db, sql: """
SELECT id, name, type, cloudPath
FROM itemMetadata
WHERE parentID = ? AND id != ?
""", arguments: [parentID, NSFileProviderItemIdentifier.rootContainerDatabaseValue])
rows: for row in rows {
let id: Int64 = row["id"]
let name: String = row["name"]
let itemType: CloudItemType = row["type"]
let storedCloudPath: CloudPath = row["cloudPath"]
visitedCount += 1
let canonical = parentPath.appendingPathComponent(name)
if storedCloudPath != canonical {
do {
try db.execute(sql: "UPDATE itemMetadata SET cloudPath = ? WHERE id = ?", arguments: [canonical, id])
} catch let error as DatabaseError where error.extendedResultCode == .SQLITE_CONSTRAINT_UNIQUE {
DDLogError("Repair migration: cloudPath \(canonical) already occupied; leaving id=\(id) at \(storedCloudPath)")
continue rows
}
}
if itemType == .folder {
queue.append((id, canonical))
Comment on lines +237 to +258

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In SQLite, are rows returned by a SELECT without ORDER BY guaranteed to have a stable order across runs or versions?

💡 Result:

No. In SQLite, if a SELECT returns more than one row and you do not specify an ORDER BY clause, the order in which rows are returned is undefined (so it is not guaranteed stable across runs, data changes, query-plan changes, or SQLite versions) [1][2]. What you can rely on instead: - Add ORDER BY to specify the ordering you need [1]. - Note that even when two rows compare equal with respect to all ORDER BY expressions, SQLite states that the relative order of those rows is undefined unless you fully break ties with additional ORDER BY terms [1]. So for “SELECT … FROM table” without ORDER BY: you should not assume a stable order, even if it appears consistent during testing [1].

Citations:


Add ORDER BY to ensure deterministic conflict repair.

Without an ORDER BY clause on line 237, the query returns siblings in undefined order. When canonical paths conflict, whichever row SQLite yields first claims the slot, making the migration outcome non-deterministic across runs and devices. Since conflicted folders are skipped entirely, different orderings can cause different subtrees to be repaired. Add ORDER BY id to guarantee consistent repair behavior.

Suggested change
 			let rows = try Row.fetchAll(db, sql: """
 			SELECT id, name, type, cloudPath
 			FROM itemMetadata
 			WHERE parentID = ? AND id != ?
+			ORDER BY id
 			""", arguments: [parentID, NSFileProviderItemIdentifier.rootContainerDatabaseValue])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let rows = try Row.fetchAll(db, sql: """
SELECT id, name, type, cloudPath
FROM itemMetadata
WHERE parentID = ? AND id != ?
""", arguments: [parentID, NSFileProviderItemIdentifier.rootContainerDatabaseValue])
rows: for row in rows {
let id: Int64 = row["id"]
let name: String = row["name"]
let itemType: CloudItemType = row["type"]
let storedCloudPath: CloudPath = row["cloudPath"]
visitedCount += 1
let canonical = parentPath.appendingPathComponent(name)
if storedCloudPath != canonical {
do {
try db.execute(sql: "UPDATE itemMetadata SET cloudPath = ? WHERE id = ?", arguments: [canonical, id])
} catch let error as DatabaseError where error.extendedResultCode == .SQLITE_CONSTRAINT_UNIQUE {
DDLogError("Repair migration: cloudPath \(canonical) already occupied; leaving id=\(id) at \(storedCloudPath)")
continue rows
}
}
if itemType == .folder {
queue.append((id, canonical))
let rows = try Row.fetchAll(db, sql: """
SELECT id, name, type, cloudPath
FROM itemMetadata
WHERE parentID = ? AND id != ?
ORDER BY id
""", arguments: [parentID, NSFileProviderItemIdentifier.rootContainerDatabaseValue])
rows: for row in rows {
let id: Int64 = row["id"]
let name: String = row["name"]
let itemType: CloudItemType = row["type"]
let storedCloudPath: CloudPath = row["cloudPath"]
visitedCount += 1
let canonical = parentPath.appendingPathComponent(name)
if storedCloudPath != canonical {
do {
try db.execute(sql: "UPDATE itemMetadata SET cloudPath = ? WHERE id = ?", arguments: [canonical, id])
} catch let error as DatabaseError where error.extendedResultCode == .SQLITE_CONSTRAINT_UNIQUE {
DDLogError("Repair migration: cloudPath \(canonical) already occupied; leaving id=\(id) at \(storedCloudPath)")
continue rows
}
}
if itemType == .folder {
queue.append((id, canonical))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CryptomatorFileProvider/DB/DatabaseHelper.swift` around lines 237 - 258, The
SELECT used in Row.fetchAll that fetches siblings for repair (the query with
WHERE parentID = ? AND id != ? used with arguments [parentID,
NSFileProviderItemIdentifier.rootContainerDatabaseValue]) has no ORDER BY,
causing non-deterministic conflict resolution; modify that SQL in DatabaseHelper
(the Row.fetchAll call) to include "ORDER BY id" so siblings are processed in a
stable order during the repair loop that updates cloudPath and enqueues folders
(the code handling visitedCount, canonical, storedCloudPath, and
queue.append((id, canonical))).

}
}
}

let totalCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM itemMetadata") ?? 0
if visitedCount < totalCount {
DDLogInfo("Repair migration: \(totalCount - visitedCount) row(s) not reachable from root, left untouched")
}
}

private static func openSharedDatabase(at databaseURL: URL, fileCoordinator: NSFileCoordinator) throws -> DatabasePool {
var coordinatorError: NSError?
var dbPool: DatabasePool?
Expand Down
20 changes: 20 additions & 0 deletions CryptomatorFileProvider/FileProviderAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -577,12 +577,32 @@ public class FileProviderAdapter: FileProviderAdapterType {
itemMetadata.parentID = parentID
itemMetadata.statusCode = .isUploading
try itemMetadataManager.updateMetadata(itemMetadata)
if itemMetadata.type == .folder, let id = itemMetadata.id {
try rewriteDescendantCloudPaths(ofFolderID: id, newParentCloudPath: cloudPath)
}

let localCachedFileInfo = try cachedFileManager.getLocalCachedFileInfo(for: itemMetadata)
let item = FileProviderItem(metadata: itemMetadata, domainIdentifier: domainIdentifier, localCachedFileInfo: localCachedFileInfo)
return MoveItemLocallyResult(item: item, reparentTaskRecord: taskRecord)
}

private func rewriteDescendantCloudPaths(ofFolderID folderID: Int64, newParentCloudPath: CloudPath) throws {
var visited: Set<Int64> = [folderID]
try rewriteDescendantCloudPaths(ofFolderID: folderID, newParentCloudPath: newParentCloudPath, visited: &visited)
}

private func rewriteDescendantCloudPaths(ofFolderID folderID: Int64, newParentCloudPath: CloudPath, visited: inout Set<Int64>) throws {
for child in try itemMetadataManager.getCachedMetadata(withParentID: folderID) {
guard let childID = child.id, visited.insert(childID).inserted else { continue }
let childCloudPath = newParentCloudPath.appendingPathComponent(child.name)
child.cloudPath = childCloudPath
try itemMetadataManager.updateMetadata(child)
if child.type == .folder {
try rewriteDescendantCloudPaths(ofFolderID: childID, newParentCloudPath: childCloudPath, visited: &visited)
}
}
}
Comment on lines 579 to +604

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preflight descendant path collisions before persisting the folder move.

Line 579 writes the moved folder row before Lines 595-601 rewrite descendants. If a leftover orphan/conflict row already occupies one of those descendant target paths, updateMetadata(child) will fail only after the parent move and reparent task record have been committed, leaving the subtree partially rewritten again. Please validate descendant target paths up front, or apply the whole rewrite in a single DB transaction/rollback boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CryptomatorFileProvider/FileProviderAdapter.swift` around lines 579 - 604,
The parent row is persisted before descendants are rewritten, which can leave a
partially-updated subtree if descendant path collisions occur; either
preflight-validate all descendant target CloudPaths for conflicts before calling
updateMetadata(itemMetadata) and committing the
MoveItemLocallyResult/taskRecord, or perform the parent update plus
rewriteDescendantCloudPaths updates inside a single DB transaction so they all
rollback on failure. Concretely: use
rewriteDescendantCloudPaths(ofFolderID:newParentCloudPath:visited:) (or a new
non-mutating collector variant) to compute all descendant target CloudPaths,
check each against the metadata store for existing conflicting rows via
itemMetadataManager lookup, and only then call
itemMetadataManager.updateMetadata(itemMetadata) and persist the reparent task;
alternatively move the parent update and calls to rewriteDescendantCloudPaths
into the same DB transaction boundary so updateMetadata(child) failures roll
back parent update and taskRecord persistence.


func validateItemName(_ name: String) throws {
do {
try ItemNameValidator.validateName(name)
Expand Down
261 changes: 261 additions & 0 deletions CryptomatorFileProviderTests/DB/DatabaseHelperMigrationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
//
// DatabaseHelperMigrationTests.swift
// CryptomatorFileProviderTests
//
// Created by Tobias Hagemann on 13.05.26.
// Copyright © 2026 Skymatic GmbH. All rights reserved.
//

import CryptomatorCloudAccessCore
import GRDB
import XCTest
@testable import CryptomatorFileProvider

/// Tests for `DatabaseHelper.repairCloudPathsMigration`.
///
/// The harness opens a fresh in-memory `DatabaseQueue`, runs `DatabaseHelper.migrate(_:)` to install
/// the schema (including a no-op v5 pass on the empty DB), seeds stale or disconnected rows via raw SQL,
/// and then invokes `DatabaseHelper.repairCloudPathsMigration(_:)` directly against the seeded state.
///
/// Seeded folder paths use the bare (`/A`) form to match what `CloudPath.appendingPathComponent` writes
/// during the repair, which makes the byte-level assertions deterministic.
class DatabaseHelperMigrationTests: XCTestCase {
var database: DatabaseWriter!

override func setUpWithError() throws {
database = try DatabaseQueue()
try DatabaseHelper.migrate(database)
}

func testRepairMigrationFixesStaleDescendant() throws {
// /A/ (id=2, no children) and /Target/ (id=3) live under root.
// B (id=4) is under /Target/ by parentID, but its stored cloudPath is stale at /A/B/.
// C.txt (id=5) is under B by parentID, with stale cloudPath /A/B/C.txt.
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(4, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/A/B', 0, 0),
(5, 'C.txt', 'file', 0, 4, NULL, 'isUploaded', '/A/B/C.txt', 0, 0)
""")
}

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

try assertCloudPath("/Target/B", forID: 4)
try assertCloudPath("/Target/B/C.txt", forID: 5)
}

func testRepairMigrationFixesDeepStaleSubtree() throws {
// Three-level subtree under /Target/ with stale paths still rooted at /A/.
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(4, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/A/B', 0, 0),
(5, 'C', 'folder', NULL, 4, NULL, 'isUploaded', '/A/B/C', 0, 0),
(6, 'D.txt', 'file', 0, 5, NULL, 'isUploaded', '/A/B/C/D.txt', 0, 0)
""")
}

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

try assertCloudPath("/Target/B", forID: 4)
try assertCloudPath("/Target/B/C", forID: 5)
try assertCloudPath("/Target/B/C/D.txt", forID: 6)
}

func testRepairMigrationLeavesCorrectRowsUntouched() throws {
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'B', 'folder', NULL, 2, NULL, 'isUploaded', '/A/B', 0, 0),
(4, 'C.txt', 'file', 0, 3, NULL, 'isUploaded', '/A/B/C.txt', 0, 0)
""")
}
let pathsBefore = try fetchCloudPathsByID()

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

let pathsAfter = try fetchCloudPathsByID()
XCTAssertEqual(pathsBefore, pathsAfter)
}

func testRepairMigrationLeavesDisconnectedRowsUntouched() throws {
// Disconnected rows must bypass foreign-key checks during seeding:
// - id=4 has parentID=9999 (no such parent).
// - id=5 and id=6 form an X↔Y cycle that is not reachable from root.
// A separate reachable stale row (id=7 under /Target/) verifies the migration still rewrites what it can.
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(7, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/A/B', 0, 0)
""")
}
// Corrupt-state seeds bypass the FK constraint on itemMetadata.parentID.
// PRAGMA foreign_keys is a no-op inside a transaction, so the seeds run via writeWithoutTransaction.
// `defer` restores FK enforcement even if any of the seed inserts throws.
try database.writeWithoutTransaction { db in
try db.execute(sql: "PRAGMA foreign_keys = OFF")
defer { try? db.execute(sql: "PRAGMA foreign_keys = ON") }
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(4, 'Orphan', 'file', 0, 9999, NULL, 'isUploaded', '/orphan', 0, 0),
(5, 'X', 'folder', NULL, 6, NULL, 'isUploaded', '/X', 0, 0),
(6, 'Y', 'folder', NULL, 5, NULL, 'isUploaded', '/Y', 0, 0)
""")
}

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

try assertCloudPath("/orphan", forID: 4)
try assertCloudPath("/X", forID: 5)
try assertCloudPath("/Y", forID: 6)
try assertCloudPath("/Target/B", forID: 7)
}

func testRepairMigrationCreatesParentIDIndex() throws {
try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}
let indexName = try database.read { db in
try String.fetchOne(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name='itemMetadata_parentID'")
}
XCTAssertEqual("itemMetadata_parentID", indexName)
}
Comment on lines +135 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

testRepairMigrationCreatesParentIDIndex is currently a false-positive risk.

This test can pass even if repairCloudPathsMigration stops creating the index, because setUpWithError already runs DatabaseHelper.migrate (which may have created it). Drop/assert-absent first, then run the repair and assert it exists.

Suggested tightening
 func testRepairMigrationCreatesParentIDIndex() throws {
 	try database.write { db in
+		try db.execute(sql: "DROP INDEX IF EXISTS itemMetadata_parentID")
 		try DatabaseHelper.repairCloudPathsMigration(db)
 	}
 	let indexName = try database.read { db in
 		try String.fetchOne(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name='itemMetadata_parentID'")
 	}
 	XCTAssertEqual("itemMetadata_parentID", indexName)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func testRepairMigrationCreatesParentIDIndex() throws {
try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}
let indexName = try database.read { db in
try String.fetchOne(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name='itemMetadata_parentID'")
}
XCTAssertEqual("itemMetadata_parentID", indexName)
}
func testRepairMigrationCreatesParentIDIndex() throws {
try database.write { db in
try db.execute(sql: "DROP INDEX IF EXISTS itemMetadata_parentID")
try DatabaseHelper.repairCloudPathsMigration(db)
}
let indexName = try database.read { db in
try String.fetchOne(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name='itemMetadata_parentID'")
}
XCTAssertEqual("itemMetadata_parentID", indexName)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CryptomatorFileProviderTests/DB/DatabaseHelperMigrationTests.swift` around
lines 135 - 143, The test testRepairMigrationCreatesParentIDIndex is a
false-positive because setUpWithError already runs DatabaseHelper.migrate which
may create the index; before calling DatabaseHelper.repairCloudPathsMigration in
the test, explicitly DROP or assert the absence of the index
(itemMetadata_parentID) within the test's database context (use
database.write/database.read as in the test), then call
DatabaseHelper.repairCloudPathsMigration and finally assert the index now
exists—this ensures the test verifies repairCloudPathsMigration actually creates
the index rather than relying on prior migrate() behavior.


func testRepairMigrationSkipsCanonicalPathConflict() throws {
// /A (id=2) and /Target (id=3) live under root.
// Row id=4 is parented to Target by parentID but its stored cloudPath is stale at /A/B.
// Row id=5 already occupies the canonical /Target/B slot — the migration must leave id=4 at its stale path and log.
// id=4 has a child id=6 at /A/B/C.txt; the migration must NOT descend into id=4's subtree after the skip, or the descendant
// would be rewritten to /Target/B/C.txt under a parent that stayed at /A/B (split subtree).
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(4, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/A/B', 0, 0),
(5, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/Target/B', 0, 0),
(6, 'C.txt', 'file', 0, 4, NULL, 'isUploaded', '/A/B/C.txt', 0, 0)
""")
}

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

try assertCloudPath("/A/B", forID: 4)
try assertCloudPath("/Target/B", forID: 5)
try assertCloudPath("/A/B/C.txt", forID: 6)
}

func testRepairMigrationFixesBranchingSubtree() throws {
// Branching tree exercises that the BFS index pointer advances across multiple
// enqueued sibling folders before descending into either subtree.
try database.write { db in
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'A', 'folder', NULL, 1, NULL, 'isUploaded', '/A', 0, 0),
(3, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(4, 'B', 'folder', NULL, 3, NULL, 'isUploaded', '/A/B', 0, 0),
(5, 'L', 'folder', NULL, 4, NULL, 'isUploaded', '/A/B/L', 0, 0),
(6, 'R', 'folder', NULL, 4, NULL, 'isUploaded', '/A/B/R', 0, 0),
(7, 'L.txt', 'file', 0, 5, NULL, 'isUploaded', '/A/B/L/L.txt', 0, 0),
(8, 'R.txt', 'file', 0, 6, NULL, 'isUploaded', '/A/B/R/R.txt', 0, 0)
""")
}

try database.write { db in
try DatabaseHelper.repairCloudPathsMigration(db)
}

try assertCloudPath("/Target/B", forID: 4)
try assertCloudPath("/Target/B/L", forID: 5)
try assertCloudPath("/Target/B/R", forID: 6)
try assertCloudPath("/Target/B/L/L.txt", forID: 7)
try assertCloudPath("/Target/B/R/R.txt", forID: 8)
}

func testRepairMigrationRunsAsRegisteredV5() throws {
// Simulate a pre-v5 install: clear v5's GRDB marker and drop the index it created, seed a stale row,
// then call DatabaseHelper.migrate(_:) so the migrator re-applies v5 end-to-end rather than via a direct helper call.
try database.write { db in
try db.execute(sql: "DELETE FROM grdb_migrations WHERE identifier = 'v5'")
try db.execute(sql: "DROP INDEX IF EXISTS itemMetadata_parentID")
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'Target', 'folder', NULL, 1, NULL, 'isUploaded', '/Target', 0, 0),
(3, 'B', 'folder', NULL, 2, NULL, 'isUploaded', '/A/B', 0, 0)
""")
}

try DatabaseHelper.migrate(database)

try assertCloudPath("/Target/B", forID: 3)
let indexName = try database.read { db in
try String.fetchOne(db, sql: "SELECT name FROM sqlite_master WHERE type='index' AND name='itemMetadata_parentID'")
}
XCTAssertEqual("itemMetadata_parentID", indexName)
}

func testRepairMigrationRunsAsRegisteredV5WithPreExistingOrphan() throws {
// Pre-existing orphan rows would fail the default deferred-FK check at COMMIT time.
// v5 registers with foreignKeyChecks: .immediate so the FK sweep only checks rows the migration modifies.
// This test proves the registered migration tolerates an orphan (`parentID = 9999`) on its way through the migrator.
try database.write { db in
try db.execute(sql: "DELETE FROM grdb_migrations WHERE identifier = 'v5'")
try db.execute(sql: "DROP INDEX IF EXISTS itemMetadata_parentID")
}
try database.writeWithoutTransaction { db in
try db.execute(sql: "PRAGMA foreign_keys = OFF")
defer { try? db.execute(sql: "PRAGMA foreign_keys = ON") }
try db.execute(sql: """
INSERT INTO itemMetadata (id, name, type, size, parentID, lastModifiedDate, statusCode, cloudPath, isPlaceholderItem, isMaybeOutdated)
VALUES
(2, 'Orphan', 'file', 0, 9999, NULL, 'isUploaded', '/orphan', 0, 0)
""")
}

try DatabaseHelper.migrate(database)

try assertCloudPath("/orphan", forID: 2)
}

private func assertCloudPath(_ expected: String, forID id: Int64, file: StaticString = #file, line: UInt = #line) throws {
let actual = try database.read { db in
try String.fetchOne(db, sql: "SELECT cloudPath FROM itemMetadata WHERE id = ?", arguments: [id])
}
XCTAssertEqual(expected, actual, "Unexpected cloudPath for id=\(id)", file: file, line: line)
}

private func fetchCloudPathsByID() throws -> [Int64: String] {
try database.read { db in
let rows = try Row.fetchAll(db, sql: "SELECT id, cloudPath FROM itemMetadata")
return rows.reduce(into: [Int64: String]()) { acc, row in
acc[row["id"]] = row["cloudPath"]
}
}
}
}
Loading
Loading