mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
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:
parent
775fba15f3
commit
0e12f9e15b
2 changed files with 126 additions and 25 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue