fix: resolve multiple critical bugs identified in code review

- fix(engine): resolve aria2 port TOCTOU race and secure conf file

- fix(server): eliminate main queue deadlock risk in local extension server

- fix(controller): ensure synchronous state save on app termination

- fix(ui): fix table skipping new items by diffing identity instead of count

- fix(scheduler): use 2-letter abbreviations to resolve day name collisions

- fix(settings): purge keychain orphans when primer access is denied

- fix(metadata): prevent synchronous DNS resolution from blocking async pool

- refactor: clean up dead code and unused variables
This commit is contained in:
NimBold
2026-06-10 19:25:06 +03:30
parent fdbacb8a7f
commit 2a5452b7c6
8 changed files with 189 additions and 104 deletions
+1 -1
View File
@@ -322,7 +322,7 @@ struct AddDownloadsView: View {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("Fetching media options...") Text("Fetching media options...")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if case .failed(let error) = firstMedia.state { } else if case .failed(_) = firstMedia.state {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text("Failed to load options.") Text("Failed to load options.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
+1 -1
View File
@@ -445,7 +445,7 @@ final class AppSettings: ObservableObject {
extensionPairingToken = Self.generateSecureToken() extensionPairingToken = Self.generateSecureToken()
} }
} else { } else {
isKeychainAccessGranted = false revokeKeychainAccess()
} }
} }
+48 -9
View File
@@ -2,7 +2,7 @@ import Foundation
import CFNetwork import CFNetwork
import Network import Network
final class Aria2DownloadEngine { final class Aria2DownloadEngine: Sendable {
struct Handle { struct Handle {
let processIdentifier: Int32 let processIdentifier: Int32
let rpcPort: Int let rpcPort: Int
@@ -107,7 +107,7 @@ final class Aria2DownloadEngine {
speedLimitKiBPerSecond: Int?, speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle { ) async throws -> Handle {
guard let executableURL else { guard let executableURL else {
throw EngineError.executableNotFound throw EngineError.executableNotFound
} }
@@ -117,19 +117,33 @@ final class Aria2DownloadEngine {
withIntermediateDirectories: true withIntermediateDirectories: true
) )
var lastError: Error?
for _ in 1...5 {
let rpcPort = Self.findFreePort() let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString let rpcSecret = UUID().uuidString
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf") let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
do {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
} catch {
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
continue
}
let confURL = tempDir.appendingPathComponent("aria2.conf")
do { do {
let confContent = "rpc-secret=\(rpcSecret)\n" let confContent = "rpc-secret=\(rpcSecret)\n"
try confContent.write(to: confURL, atomically: true, encoding: .utf8) try confContent.write(to: confURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
} catch { } catch {
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)") lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
continue
} }
let process = Process() let process = Process()
process.executableURL = executableURL process.executableURL = executableURL
do {
process.arguments = try arguments( process.arguments = try arguments(
for: item, for: item,
proxyConfiguration: proxyConfiguration, proxyConfiguration: proxyConfiguration,
@@ -137,6 +151,10 @@ final class Aria2DownloadEngine {
rpcPort: rpcPort, rpcPort: rpcPort,
confURL: confURL confURL: confURL
) )
} catch {
lastError = error
break
}
let inputPipe = Pipe() let inputPipe = Pipe()
let outputPipe = Pipe() let outputPipe = Pipe()
@@ -169,7 +187,7 @@ final class Aria2DownloadEngine {
} }
process.terminationHandler = { finishedProcess in process.terminationHandler = { finishedProcess in
try? FileManager.default.removeItem(at: confURL) try? FileManager.default.removeItem(at: tempDir)
completionMonitor.cancel() completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil
@@ -190,6 +208,7 @@ final class Aria2DownloadEngine {
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message))) completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
} }
var didThrow = false
do { do {
try process.run() try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) { if let input = inputFileContent(for: item).data(using: .utf8) {
@@ -197,8 +216,25 @@ final class Aria2DownloadEngine {
} }
inputPipe.fileHandleForWriting.closeFile() inputPipe.fileHandleForWriting.closeFile()
} catch { } catch {
try? FileManager.default.removeItem(at: confURL) didThrow = true
throw EngineError.launchFailed(error.localizedDescription) lastError = EngineError.launchFailed(error.localizedDescription)
}
if didThrow {
try? FileManager.default.removeItem(at: tempDir)
continue
}
try? await Task.sleep(nanoseconds: 100_000_000)
if !process.isRunning {
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
try? FileManager.default.removeItem(at: tempDir)
continue
}
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
// But the terminationHandler will hit completionGate, so we just return the handle.
} }
completionMonitor.set( completionMonitor.set(
@@ -215,10 +251,13 @@ final class Aria2DownloadEngine {
if process.isRunning { if process.isRunning {
process.terminate() process.terminate()
} }
try? FileManager.default.removeItem(at: confURL) try? FileManager.default.removeItem(at: tempDir)
} }
} }
throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
}
private static func monitorCompletion( private static func monitorCompletion(
rpcPort: Int, rpcPort: Int,
rpcSecret: String, rpcSecret: String,
+33 -10
View File
@@ -74,7 +74,7 @@ final class DownloadController: ObservableObject {
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.saveDownloads() self?.saveDownloadsSync()
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -371,12 +371,7 @@ final class DownloadController: ObservableObject {
pumpQueue() pumpQueue()
} }
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
delete(item, deleteFiles: deleteFiles)
}
}
func delete(_ item: DownloadItem, deleteFiles: Bool = false) { func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel() activeHandles[item.id]?.cancel()
@@ -598,11 +593,13 @@ final class DownloadController: ObservableObject {
} }
} }
} else { } else {
Task {
do { do {
let handle = try engine.start( let liveItem = injectedEngineItem(from: item)
item: injectedEngineItem(from: item), let handle = try await engine.start(
item: liveItem,
proxyConfiguration: settings.downloadProxyConfiguration, proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in progress: { [weak self] progress in
Task { @MainActor in Task { @MainActor in
let now = Date() let now = Date()
@@ -627,6 +624,12 @@ final class DownloadController: ObservableObject {
} }
} }
) )
await MainActor.run {
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeHandles[item.id] = handle activeHandles[item.id] = handle
update(item.id) { update(item.id) {
$0.rpcPort = handle.rpcPort $0.rpcPort = handle.rpcPort
@@ -636,7 +639,9 @@ final class DownloadController: ObservableObject {
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads() applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
}
} catch { } catch {
await MainActor.run {
handleDownloadFailure(itemID: item.id, error: error) handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads() applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
@@ -644,6 +649,8 @@ final class DownloadController: ObservableObject {
} }
} }
} }
}
}
private func activeDownloadItem(id: UUID) -> DownloadItem? { private func activeDownloadItem(id: UUID) -> DownloadItem? {
downloads.first { $0.id == id && $0.status == .downloading } downloads.first { $0.id == id && $0.status == .downloading }
@@ -996,6 +1003,22 @@ final class DownloadController: ObservableObject {
} }
} }
private func saveDownloadsSync() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to synchronously save downloads: \(error)")
}
}
private func saveDownloads() { private func saveDownloads() {
let queuesCopy = queues let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence) let downloadsCopy = downloads.map(\.redactedForPersistence)
@@ -54,10 +54,15 @@ enum DownloadMetadataFetcher {
return pending return pending
} }
if isAutoFetch, let host = url.host, isPrivateHost(host) { if isAutoFetch, let host = url.host {
let isPrivate = await Task.detached {
isPrivateHost(host)
}.value
if isPrivate {
pending.state = .loaded pending.state = .loaded
return pending return pending
} }
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "HEAD" request.httpMethod = "HEAD"
+3 -1
View File
@@ -126,7 +126,9 @@ struct DownloadTable: View {
} }
.onAppear { sortedItems = items.sorted(using: sortOrder) } .onAppear { sortedItems = items.sorted(using: sortOrder) }
.onChange(of: items) { _, newItems in .onChange(of: items) { _, newItems in
if newItems.count != sortedItems.count { let existingIDs = Set(sortedItems.map(\.id))
let newIDs = Set(newItems.map(\.id))
if existingIDs != newIDs {
sortedItems = newItems.sorted(using: sortOrder) sortedItems = newItems.sorted(using: sortOrder)
} else { } else {
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
+17 -1
View File
@@ -1,6 +1,7 @@
import Foundation import Foundation
import Network import Network
import AppKit import AppKit
import Combine
final class LocalExtensionServer: @unchecked Sendable { final class LocalExtensionServer: @unchecked Sendable {
private enum Constants { private enum Constants {
@@ -17,6 +18,11 @@ final class LocalExtensionServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "local.firelink.server") private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16 let port: UInt16
private let tokenLock = NSLock()
private var _pairingToken: String = ""
private var cancellables = Set<AnyCancellable>()
@MainActor
init?(downloadController: DownloadController, settings: AppSettings) { init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController self.downloadController = downloadController
self.settings = settings self.settings = settings
@@ -42,6 +48,16 @@ final class LocalExtensionServer: @unchecked Sendable {
self.listener = createdListener self.listener = createdListener
self.port = selectedPort ?? 6412 self.port = selectedPort ?? 6412
settings.$extensionPairingToken
.sink { [weak self] token in
self?.tokenLock.withLock { self?._pairingToken = token }
}
.store(in: &cancellables)
}
private var currentPairingToken: String {
tokenLock.withLock { _pairingToken }
} }
func start() { func start() {
@@ -139,7 +155,7 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
} }
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken } let expectedToken = currentPairingToken
guard let token = request.header(named: Constants.extensionRequestHeader), guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else { token == expectedToken else {
return .forbidden return .forbidden
+7 -7
View File
@@ -18,13 +18,13 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
var shortName: String { var shortName: String {
switch self { switch self {
case .sunday: "S" case .sunday: "Su"
case .monday: "M" case .monday: "Mo"
case .tuesday: "T" case .tuesday: "Tu"
case .wednesday: "W" case .wednesday: "We"
case .thursday: "T" case .thursday: "Th"
case .friday: "F" case .friday: "Fr"
case .saturday: "S" case .saturday: "Sa"
} }
} }
} }