From 0e12f9e15b2f3a2a8288c647157f7d0330573918 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:31:08 -0700 Subject: [PATCH] Prevent stale legacy pairing files overriding fresh imports The store now prefers the canonical Documents file whenever it exists and falls back to the legacy file only when it is absent. Directory resolution consistently uses the supplied FileManager, allowing isolated regression tests for precedence, imports, migration, permissions, and cleanup. Temporary-file cleanup is registered before copying so it also runs if the copy fails. Fixes #471 --- StikDebug/Support/PairingFileStore.swift | 50 +++++------ StikDebugTests/StikDebugTests.swift | 101 +++++++++++++++++++++++ 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/StikDebug/Support/PairingFileStore.swift b/StikDebug/Support/PairingFileStore.swift index 283e13e..c0d0a48 100644 --- a/StikDebug/Support/PairingFileStore.swift +++ b/StikDebug/Support/PairingFileStore.swift @@ -17,13 +17,13 @@ enum PairingFileStore { private static let legacyFileName = "rp_pairing_file.plist" static var url: URL { - directoryURL.appendingPathComponent(fileName) + directoryURL(fileManager: .default).appendingPathComponent(fileName) } @discardableResult static func prepareURL(fileManager: FileManager = .default) -> URL { - let destination = url - try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let destination = directoryURL(fileManager: fileManager).appendingPathComponent(fileName) + try? fileManager.createDirectory(at: directoryURL(fileManager: fileManager), withIntermediateDirectories: true) if let documentURL = documentSourceURL(fileManager: fileManager) { if !fileManager.fileExists(atPath: destination.path) || @@ -41,15 +41,16 @@ enum PairingFileStore { } static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: directoryURL(fileManager: fileManager), withIntermediateDirectories: true) - let documentsDestination = documentsURL + let documentsDestination = documentsURL(fileManager: fileManager) if sourceURL.standardizedFileURL != documentsDestination.standardizedFileURL { try replaceItem(at: documentsDestination, with: sourceURL, fileManager: fileManager) } - try replaceItem(at: url, with: documentsDestination, fileManager: fileManager) - protectPairingFile(at: url, fileManager: fileManager) + let destination = directoryURL(fileManager: fileManager).appendingPathComponent(fileName) + try replaceItem(at: destination, with: documentsDestination, fileManager: fileManager) + protectPairingFile(at: destination, fileManager: fileManager) } static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { @@ -75,36 +76,35 @@ enum PairingFileStore { removeLegacyCopies(fileManager: fileManager) } - private static var directoryURL: URL { - FileManager.default + private static func directoryURL(fileManager: FileManager) -> URL { + fileManager .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Pairing", isDirectory: true) } - private static var legacyURLs: [URL] { + private static func legacyURLs(fileManager: FileManager) -> [URL] { [ - directoryURL.appendingPathComponent(legacyFileName), - documentsURL, - URL.documentsDirectory.appendingPathComponent(legacyFileName) + directoryURL(fileManager: fileManager).appendingPathComponent(legacyFileName), + documentsURL(fileManager: fileManager), + fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent(legacyFileName) ] } - private static var documentsURL: URL { - URL.documentsDirectory.appendingPathComponent(fileName) + private static func documentsURL(fileManager: FileManager) -> URL { + fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent(fileName) } private static func documentSourceURL(fileManager: FileManager) -> URL? { - [documentsURL, URL.documentsDirectory.appendingPathComponent(legacyFileName)] - .filter { fileManager.fileExists(atPath: $0.path) } - .max { - let firstDate = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast - let secondDate = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast - return firstDate < secondDate - } + let canonical = documentsURL(fileManager: fileManager) + let legacy = canonical.deletingLastPathComponent().appendingPathComponent(legacyFileName) + // Imports can preserve old timestamps; the canonical file still takes precedence. + return [canonical, legacy].first { fileManager.fileExists(atPath: $0.path) } } private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) { - for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { + for legacyURL in legacyURLs(fileManager: fileManager) where fileManager.fileExists(atPath: legacyURL.path) { do { try replaceItem(at: destination, with: legacyURL, fileManager: fileManager) } catch { @@ -123,8 +123,8 @@ enum PairingFileStore { .deletingLastPathComponent() .appendingPathComponent(UUID().uuidString + ".tmp") try? fileManager.removeItem(at: temporary) - try fileManager.copyItem(at: source, to: temporary) defer { try? fileManager.removeItem(at: temporary) } + try fileManager.copyItem(at: source, to: temporary) if fileManager.fileExists(atPath: destination.path) { _ = try fileManager.replaceItemAt(destination, withItemAt: temporary) @@ -134,7 +134,7 @@ enum PairingFileStore { } private static func removeLegacyCopies(fileManager: FileManager) { - for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { + for legacyURL in legacyURLs(fileManager: fileManager) where fileManager.fileExists(atPath: legacyURL.path) { try? fileManager.removeItem(at: legacyURL) } } diff --git a/StikDebugTests/StikDebugTests.swift b/StikDebugTests/StikDebugTests.swift index 97c75ec..3e46542 100644 --- a/StikDebugTests/StikDebugTests.swift +++ b/StikDebugTests/StikDebugTests.swift @@ -11,6 +11,86 @@ import Testing struct StikDebugTests { + @Test func pairingImportSurvivesNewerLegacyFile() throws { + let fileManager = try PairingFileManager() + defer { try? fileManager.removeItem(at: fileManager.root) } + let canonical = fileManager.documents.appendingPathComponent(PairingFileStore.fileName) + let legacy = fileManager.documents.appendingPathComponent("rp_pairing_file.plist") + let source = fileManager.root.appendingPathComponent("import.plist") + let imported = Data("imported pairing".utf8) + try Data("stale legacy pairing".utf8).write(to: legacy) + try fileManager.setAttributes([.modificationDate: Date(timeIntervalSince1970: 200)], ofItemAtPath: legacy.path) + try imported.write(to: source) + try fileManager.setAttributes([.modificationDate: Date(timeIntervalSince1970: 100)], ofItemAtPath: source.path) + + try PairingFileStore.importFromPicker(source, fileManager: fileManager) + for _ in 0..<2 { + let active = PairingFileStore.prepareURL(fileManager: fileManager) + #expect(try Data(contentsOf: active) == imported) + #expect(try fileManager.attributesOfItem(atPath: active.path)[.posixPermissions] as? Int == 0o600) + } + try PairingFileStore.importFromPicker(canonical, fileManager: fileManager) + #expect(throws: (any Error).self) { + try PairingFileStore.importFromPicker(fileManager.root.appendingPathComponent("missing.plist"), fileManager: fileManager) + } + #expect(throws: (any Error).self) { + try PairingFileStore.replace(with: fileManager.root.appendingPathComponent("missing.plist"), fileManager: fileManager) + } + #expect(try Data(contentsOf: canonical) == imported) + let active = fileManager.support.appendingPathComponent("Pairing").appendingPathComponent(PairingFileStore.fileName) + #expect(try Data(contentsOf: active) == imported) + for directory in [fileManager.documents, active.deletingLastPathComponent()] { + #expect(try fileManager.contentsOfDirectory(atPath: directory.path).allSatisfy { !$0.hasSuffix(".tmp") }) + } + try PairingFileStore.remove(fileManager: fileManager) + #expect(!fileManager.fileExists(atPath: active.path)) + #expect(!fileManager.fileExists(atPath: canonical.path)) + #expect(!fileManager.fileExists(atPath: legacy.path)) + } + + @Test(arguments: [100.0, 200.0, 300.0]) + func canonicalPairingTakesPrecedence(modificationTime: TimeInterval) throws { + let fileManager = try PairingFileManager() + defer { try? fileManager.removeItem(at: fileManager.root) } + let canonical = fileManager.documents.appendingPathComponent(PairingFileStore.fileName) + let legacy = fileManager.documents.appendingPathComponent("rp_pairing_file.plist") + let active = PairingFileStore.prepareURL(fileManager: fileManager) + try Data("stale active pairing".utf8).write(to: active) + try Data("legacy pairing".utf8).write(to: legacy) + try fileManager.setAttributes([.modificationDate: Date(timeIntervalSince1970: 200)], ofItemAtPath: legacy.path) + let canonicalData = Data("canonical pairing".utf8) + try canonicalData.write(to: canonical) + try fileManager.setAttributes([.modificationDate: Date(timeIntervalSince1970: modificationTime)], ofItemAtPath: canonical.path) + #expect(try Data(contentsOf: PairingFileStore.prepareURL(fileManager: fileManager)) == canonicalData) + + let edited = Data("edited through Files".utf8) + try edited.write(to: canonical) + try fileManager.setAttributes([.modificationDate: Date(timeIntervalSince1970: 50)], ofItemAtPath: canonical.path) + #expect(try Data(contentsOf: PairingFileStore.prepareURL(fileManager: fileManager)) == edited) + } + + @Test(arguments: ["documents", "support", "none"]) + func pairingLegacyMigration(source: String) throws { + let fileManager = try PairingFileManager() + defer { try? fileManager.removeItem(at: fileManager.root) } + let pairingDirectory = fileManager.support.appendingPathComponent("Pairing") + let expected = pairingDirectory.appendingPathComponent(PairingFileStore.fileName) + let data = Data("legacy pairing".utf8) + if source != "none" { + let directory = source == "documents" ? fileManager.documents : pairingDirectory + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent("rp_pairing_file.plist")) + } + let active = PairingFileStore.prepareURL(fileManager: fileManager) + #expect(active == expected) + if source == "none" { + #expect(!fileManager.fileExists(atPath: active.path)) + } else { + #expect(try Data(contentsOf: active) == data) + #expect(try fileManager.attributesOfItem(atPath: active.path)[.posixPermissions] as? Int == 0o600) + } + } + @Test func txmDetectionUsesClassicTXMBeforeIOS266() async throws { #expect( ProcessInfo.hasTXMSupport( @@ -79,3 +159,24 @@ struct StikDebugTests { } } + +private final class PairingFileManager: FileManager, @unchecked Sendable { + let root: URL + var documents: URL { root.appendingPathComponent("Documents") } + var support: URL { root.appendingPathComponent("Application Support") } + + init(root: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) throws { + self.root = root + super.init() + try createDirectory(at: documents, withIntermediateDirectories: true) + try createDirectory(at: support, withIntermediateDirectories: true) + } + + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + switch directory { + case .documentDirectory: return [documents] + case .applicationSupportDirectory: return [support] + default: return super.urls(for: directory, in: domainMask) + } + } +}