Skip to content

Commit d8ba974

Browse files
authored
feat: add runtime validation of plugin driver descriptors (#310)
* feat: make ConnectionFormView fully dynamic via plugin metadata (#309) * fix: remove dead code, restore Redis default, add Codable backward compat * fix: address PR review — filter Advanced tab, normalize selected tab, harden redis parsing * fix: use ConnectionFieldRow for auth fields, preserve nil redisDatabase for non-Redis * feat: add runtime validation of plugin driver descriptors * fix: address PR review — fix partial registration, wire up dead code, rewrite tests * fix: wire up validateDialectDescriptor, serialize mock-based test suite
1 parent aeb3aea commit d8ba974

File tree

4 files changed

+404
-14
lines changed

4 files changed

+404
-14
lines changed

TablePro/Core/Plugins/PluginError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ enum PluginError: LocalizedError {
1919
case downloadFailed(String)
2020
case pluginNotInstalled(String)
2121
case incompatibleWithCurrentApp(minimumRequired: String)
22+
case invalidDescriptor(pluginId: String, reason: String)
2223

2324
var errorDescription: String? {
2425
switch self {
@@ -48,6 +49,8 @@ enum PluginError: LocalizedError {
4849
return String(localized: "The \(databaseType) plugin is not installed. You can download it from the plugin marketplace.")
4950
case .incompatibleWithCurrentApp(let minimumRequired):
5051
return String(localized: "This plugin requires TablePro \(minimumRequired) or later")
52+
case .invalidDescriptor(let pluginId, let reason):
53+
return String(localized: "Plugin '\(pluginId)' has an invalid descriptor: \(reason)")
5154
}
5255
}
5356
}

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ final class PluginManager {
6262
private static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager")
6363

6464
private var pendingPluginURLs: [(url: URL, source: PluginSource)] = []
65+
private var validatedConnectionFieldPlugins: Set<String> = []
66+
private var validatedDialectPlugins: Set<String> = []
6567

6668
private init() {}
6769

@@ -242,19 +244,27 @@ final class PluginManager {
242244
// MARK: - Capability Registration
243245

244246
private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) {
245-
pluginInstances[pluginId] = instance
246247
let declared = Set(type(of: instance).capabilities)
248+
var registeredAny = false
247249

248250
if let driver = instance as? any DriverPlugin {
249251
if !declared.contains(.databaseDriver) {
250252
Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway")
251253
}
252-
let typeId = type(of: driver).databaseTypeId
253-
driverPlugins[typeId] = driver
254-
for additionalId in type(of: driver).additionalDatabaseTypeIds {
255-
driverPlugins[additionalId] = driver
254+
do {
255+
try validateDriverDescriptor(type(of: driver), pluginId: pluginId)
256+
} catch {
257+
Self.logger.error("Plugin '\(pluginId)' driver rejected: \(error.localizedDescription)")
258+
}
259+
if !driverPlugins.keys.contains(type(of: driver).databaseTypeId) {
260+
let typeId = type(of: driver).databaseTypeId
261+
driverPlugins[typeId] = driver
262+
for additionalId in type(of: driver).additionalDatabaseTypeIds {
263+
driverPlugins[additionalId] = driver
264+
}
265+
Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
266+
registeredAny = true
256267
}
257-
Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'")
258268
}
259269

260270
if let exportPlugin = instance as? any ExportFormatPlugin {
@@ -264,6 +274,7 @@ final class PluginManager {
264274
let formatId = type(of: exportPlugin).formatId
265275
exportPlugins[formatId] = exportPlugin
266276
Self.logger.debug("Registered export plugin '\(pluginId)' for format '\(formatId)'")
277+
registeredAny = true
267278
}
268279

269280
if let importPlugin = instance as? any ImportFormatPlugin {
@@ -273,6 +284,11 @@ final class PluginManager {
273284
let formatId = type(of: importPlugin).formatId
274285
importPlugins[formatId] = importPlugin
275286
Self.logger.debug("Registered import plugin '\(pluginId)' for format '\(formatId)'")
287+
registeredAny = true
288+
}
289+
290+
if registeredAny {
291+
pluginInstances[pluginId] = instance
276292
}
277293
}
278294

@@ -293,6 +309,77 @@ final class PluginManager {
293309
}
294310
}
295311

312+
// MARK: - Descriptor Validation
313+
314+
/// Reject-level validation: runs synchronously before registration.
315+
/// Checks only properties already accessed during the loading flow.
316+
func validateDriverDescriptor(_ driverType: any DriverPlugin.Type, pluginId: String) throws {
317+
guard !driverType.databaseTypeId.trimmingCharacters(in: .whitespaces).isEmpty else {
318+
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseTypeId is empty")
319+
}
320+
321+
guard !driverType.databaseDisplayName.trimmingCharacters(in: .whitespaces).isEmpty else {
322+
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseDisplayName is empty")
323+
}
324+
325+
let typeId = driverType.databaseTypeId
326+
if let existingPlugin = driverPlugins[typeId] {
327+
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
328+
throw PluginError.invalidDescriptor(
329+
pluginId: pluginId,
330+
reason: "databaseTypeId '\(typeId)' is already registered by '\(existingName)'"
331+
)
332+
}
333+
334+
let allAdditionalIds = driverType.additionalDatabaseTypeIds
335+
// Warn-only (not reject): redundant but harmless — the primary ID is already registered,
336+
// so the duplicate entry in additionalIds just overwrites with the same value.
337+
// Cross-plugin duplicates are rejected above because they indicate a real conflict.
338+
if allAdditionalIds.contains(typeId) {
339+
Self.logger.warning("Plugin '\(pluginId)': additionalDatabaseTypeIds contains the primary databaseTypeId '\(typeId)'")
340+
}
341+
342+
for additionalId in allAdditionalIds {
343+
if let existingPlugin = driverPlugins[additionalId] {
344+
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
345+
throw PluginError.invalidDescriptor(
346+
pluginId: pluginId,
347+
reason: "additionalDatabaseTypeId '\(additionalId)' is already registered by '\(existingName)'"
348+
)
349+
}
350+
}
351+
}
352+
353+
/// Warn-level connection field validation. Called lazily on first access via
354+
/// `additionalConnectionFields(for:)`, not during plugin loading (protocol witness
355+
/// tables may be unstable for dynamically loaded bundles during the loading path).
356+
func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) {
357+
var seenIds = Set<String>()
358+
for field in fields {
359+
if field.id.trimmingCharacters(in: .whitespaces).isEmpty {
360+
Self.logger.warning("Plugin '\(pluginId)': connection field has empty id")
361+
}
362+
if field.label.trimmingCharacters(in: .whitespaces).isEmpty {
363+
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' has empty label")
364+
}
365+
if !seenIds.insert(field.id).inserted {
366+
Self.logger.warning("Plugin '\(pluginId)': duplicate connection field id '\(field.id)'")
367+
}
368+
if case .dropdown(let options) = field.fieldType, options.isEmpty {
369+
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' is a dropdown with no options")
370+
}
371+
}
372+
}
373+
374+
private func validateDialectDescriptor(_ dialect: SQLDialectDescriptor, pluginId: String) {
375+
if dialect.identifierQuote.trimmingCharacters(in: .whitespaces).isEmpty {
376+
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.identifierQuote is empty")
377+
}
378+
if dialect.keywords.isEmpty {
379+
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.keywords is empty")
380+
}
381+
}
382+
296383
private func replaceExistingPlugin(bundleId: String) {
297384
guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return }
298385
// Order matters: unregisterCapabilities reads from `plugins` to find the principal class
@@ -360,7 +447,13 @@ final class PluginManager {
360447
func sqlDialect(for databaseType: DatabaseType) -> SQLDialectDescriptor? {
361448
loadPendingPlugins()
362449
guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return nil }
363-
return Swift.type(of: plugin).sqlDialect
450+
let dialect = Swift.type(of: plugin).sqlDialect
451+
let pluginId = databaseType.pluginTypeId
452+
if let dialect, !validatedDialectPlugins.contains(pluginId) {
453+
validatedDialectPlugins.insert(pluginId)
454+
validateDialectDescriptor(dialect, pluginId: pluginId)
455+
}
456+
return dialect
364457
}
365458

366459
func statementCompletions(for databaseType: DatabaseType) -> [CompletionEntry] {
@@ -372,7 +465,13 @@ final class PluginManager {
372465
func additionalConnectionFields(for databaseType: DatabaseType) -> [ConnectionField] {
373466
loadPendingPlugins()
374467
guard let plugin = driverPlugins[databaseType.pluginTypeId] else { return [] }
375-
return Swift.type(of: plugin).additionalConnectionFields
468+
let fields = Swift.type(of: plugin).additionalConnectionFields
469+
let pluginId = databaseType.pluginTypeId
470+
if !validatedConnectionFieldPlugins.contains(pluginId) {
471+
validatedConnectionFieldPlugins.insert(pluginId)
472+
validateConnectionFields(fields, pluginId: pluginId)
473+
}
474+
return fields
376475
}
377476

378477
// MARK: - Plugin Property Lookups

0 commit comments

Comments
 (0)