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
This commit is contained in:
Matt Van Horn 2026-10-10 22:31:08 -07:00
parent 775fba15f3
commit 0e12f9e15b
No known key found for this signature in database
2 changed files with 126 additions and 25 deletions

View file

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

View file

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