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) + } + } +}