Skip to content

Commit 34eb487

Browse files
committed
feat: add runtime validation of plugin driver descriptors
1 parent b5fe3ad commit 34eb487

File tree

4 files changed

+290
-6
lines changed

4 files changed

+290
-6
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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ final class PluginManager {
249249
if !declared.contains(.databaseDriver) {
250250
Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway")
251251
}
252+
do {
253+
try validateDriverDescriptor(type(of: driver), pluginId: pluginId)
254+
} catch {
255+
Self.logger.error("Plugin '\(pluginId)' rejected: \(error.localizedDescription)")
256+
return
257+
}
252258
let typeId = type(of: driver).databaseTypeId
253259
driverPlugins[typeId] = driver
254260
for additionalId in type(of: driver).additionalDatabaseTypeIds {
@@ -293,6 +299,73 @@ final class PluginManager {
293299
}
294300
}
295301

302+
// MARK: - Descriptor Validation
303+
304+
/// Reject-level validation: runs synchronously before registration.
305+
/// Checks only properties already accessed during the loading flow.
306+
private func validateDriverDescriptor(_ driverType: any DriverPlugin.Type, pluginId: String) throws {
307+
guard !driverType.databaseTypeId.trimmingCharacters(in: .whitespaces).isEmpty else {
308+
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseTypeId is empty")
309+
}
310+
311+
guard !driverType.databaseDisplayName.trimmingCharacters(in: .whitespaces).isEmpty else {
312+
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseDisplayName is empty")
313+
}
314+
315+
let typeId = driverType.databaseTypeId
316+
if let existingPlugin = driverPlugins[typeId] {
317+
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
318+
throw PluginError.invalidDescriptor(
319+
pluginId: pluginId,
320+
reason: "databaseTypeId '\(typeId)' is already registered by '\(existingName)'"
321+
)
322+
}
323+
324+
let allAdditionalIds = driverType.additionalDatabaseTypeIds
325+
if allAdditionalIds.contains(typeId) {
326+
Self.logger.warning("Plugin '\(pluginId)': additionalDatabaseTypeIds contains the primary databaseTypeId '\(typeId)'")
327+
}
328+
329+
for additionalId in allAdditionalIds {
330+
if let existingPlugin = driverPlugins[additionalId] {
331+
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
332+
throw PluginError.invalidDescriptor(
333+
pluginId: pluginId,
334+
reason: "additionalDatabaseTypeId '\(additionalId)' is already registered by '\(existingName)'"
335+
)
336+
}
337+
}
338+
}
339+
340+
/// Warn-level connection field validation. Called lazily when fields are accessed,
341+
/// not during plugin loading (protocol witness tables may be unstable for dynamically loaded bundles).
342+
private func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) {
343+
var seenIds = Set<String>()
344+
for field in fields {
345+
if field.id.trimmingCharacters(in: .whitespaces).isEmpty {
346+
Self.logger.warning("Plugin '\(pluginId)': connection field has empty id")
347+
}
348+
if field.label.trimmingCharacters(in: .whitespaces).isEmpty {
349+
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' has empty label")
350+
}
351+
if !seenIds.insert(field.id).inserted {
352+
Self.logger.warning("Plugin '\(pluginId)': duplicate connection field id '\(field.id)'")
353+
}
354+
if case .dropdown(let options) = field.fieldType, options.isEmpty {
355+
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' is a dropdown with no options")
356+
}
357+
}
358+
}
359+
360+
private func validateDialectDescriptor(_ dialect: SQLDialectDescriptor, pluginId: String) {
361+
if dialect.identifierQuote.trimmingCharacters(in: .whitespaces).isEmpty {
362+
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.identifierQuote is empty")
363+
}
364+
if dialect.keywords.isEmpty {
365+
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.keywords is empty")
366+
}
367+
}
368+
296369
private func replaceExistingPlugin(bundleId: String) {
297370
guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return }
298371
// Order matters: unregisterCapabilities reads from `plugins` to find the principal class

0 commit comments

Comments
 (0)