diff --git a/DBSyn.xcodeproj/project.pbxproj b/DBSyn.xcodeproj/project.pbxproj index 46ac005..c7bb92b 100644 --- a/DBSyn.xcodeproj/project.pbxproj +++ b/DBSyn.xcodeproj/project.pbxproj @@ -221,8 +221,8 @@ ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; - MARKETING_VERSION = 1.0.5; - CURRENT_PROJECT_VERSION = 78; + MARKETING_VERSION = 1.0.8; + CURRENT_PROJECT_VERSION = 81; ENABLE_TESTABILITY = YES; ONLY_ACTIVE_ARCH = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; @@ -244,8 +244,8 @@ ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; - MARKETING_VERSION = 1.0.5; - CURRENT_PROJECT_VERSION = 78; + MARKETING_VERSION = 1.0.8; + CURRENT_PROJECT_VERSION = 81; ENABLE_TESTABILITY = YES; ONLY_ACTIVE_ARCH = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 5b4ce56..1f3f1b6 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -7703,6 +7703,7 @@ private struct DBSynInlineRowEditor: View { @State private var executing = false @State private var errorText: String? @State private var passwordColumnIndex: Int? + @State private var generatedPasswordProof: (index: Int, hash: String)? init(model: DatabaseModel, database: String, table: String, columns: [String], original: [String]) { @@ -7733,9 +7734,12 @@ private struct DBSynInlineRowEditor: View { } private func literal(_ value: String) -> String { - // MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8 - // literals avoid SQL injection and preserve quote/backslash characters. - "CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)" + // Human-readable SQL for ordinary values. Fall back to UTF-8 hex + // for control characters or backslashes (SQL mode dependent). + if value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) || value.contains("\\") { + return "CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)" + } + return "'" + value.replacingOccurrences(of: "'", with: "''") + "'" } private var validationError: String? { @@ -7760,15 +7764,11 @@ private struct DBSynInlineRowEditor: View { let assignments = changedIndices.map { i in "\(identifier(columns[i])) = \(literal(values[i]))" }.joined(separator: ",\n ") - // Optimistic checks prevent overwriting a value modified since the - // result grid was loaded. LIMIT 1 prevents accidental mass updates. - // Compare original values byte-for-byte. MySQL may use a different - // connection collation from the column (e.g. utf8mb4_0900_ai_ci vs - // utf8mb4_unicode_ci), so ordinary string <=> can fail with error 1267. - // Binary comparisons preserve optimistic concurrency without relying - // on implicit collation coercion. - let checks = (primaryIndices + changedIndices).map { i in - "CAST(\(identifier(columns[i])) AS BINARY) <=> CAST(\(literal(original[i])) AS BINARY)" + // Match only the original primary key. Comparing a previous text + // value (particularly password hashes) can trigger collation errors. + // Restrict to one row even if the database metadata is stale. + let checks = primaryIndices.map { i in + "\(identifier(columns[i])) = \(literal(original[i]))" }.joined(separator: " AND\n ") return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;" } @@ -7838,6 +7838,7 @@ private struct DBSynInlineRowEditor: View { DBSynLaravelPasswordGenerator { hash in guard values.indices.contains(selection.index) else { return } values[selection.index] = hash + generatedPasswordProof = (selection.index, hash) passwordColumnIndex = nil } } @@ -7852,6 +7853,11 @@ private struct DBSynInlineRowEditor: View { private func apply() { guard validationError == nil, let profile = model.selected else { return } let statement = generatedSQL + let verifiedPassword = generatedPasswordProof.flatMap { proof -> (column: String, hash: String)? in + guard values.indices.contains(proof.index), values[proof.index] == proof.hash else { return nil } + return (columns[proof.index], proof.hash) + } + let checks = primaryIndices.map { i in "\(identifier(columns[i])) = \(literal(original[i]))" }.joined(separator: " AND ") let password = PasswordStore.read(profile.id) executing = true errorText = nil @@ -7859,9 +7865,19 @@ private struct DBSynInlineRowEditor: View { do { let response = try await NativeMySQLRunner.run( profile: profile, database: database, password: password, sql: statement) - // A successful UPDATE may affect zero rows when a concurrent - // change invalidates the optimistic predicate; do not claim a save. - model.status = "UPDATE completed: \(response.message). Refresh results to verify the row." + if let verifiedPassword { + let checkSQL = "SELECT \(identifier(verifiedPassword.column)) FROM \(identifier(database)).\(identifier(table)) WHERE \(checks) LIMIT 1" + let saved = try await NativeMySQLRunner.run(profile: profile, database: database, password: password, sql: checkSQL) + guard let stored = saved.rows.first?.first else { + throw LaravelBcryptService.HashError.failed("Password update could not be verified: row not found.") + } + guard stored == verifiedPassword.hash else { + throw LaravelBcryptService.HashError.failed("Password update could not be verified: the stored hash differs from the generated hash.") + } + model.status = "Password hash saved and read-back verified." + } else { + model.status = "UPDATE completed: \(response.message). Refresh results to verify the row." + } dismiss() model.runSQL() } catch { @@ -7887,7 +7903,7 @@ private enum LaravelBcryptService { var errorDescription: String? { switch self { case .noPHP: - return "PHP CLI was not found. Install PHP or specify its full executable path. Laravel bcrypt requires PHP's password_hash()." + return "PHP CLI was not found. Check the configured path or install ServBay/Homebrew PHP." case .failed(let message): return message } } @@ -7914,16 +7930,36 @@ private enum LaravelBcryptService { return String(result.prefix(length)) } + static func detectedPHPPath(manualPath: String = "") -> String? { + let fm = FileManager.default + let manual = manualPath.trimmingCharacters(in: .whitespacesAndNewlines) + if !manual.isEmpty { return fm.isExecutableFile(atPath: manual) ? manual : nil } + var candidates: [String] = [] + let base = "/Applications/ServBay/package/php" + if let versions = try? fm.contentsOfDirectory(atPath: base) { + for major in versions { + let folder = base + "/" + major + if let builds = try? fm.contentsOfDirectory(atPath: folder) { + for build in builds { candidates.append(folder + "/" + build + "/bin/php") } + } + } + } + candidates.sort { left, right in + let a = left.split(separator: "/").compactMap { Int($0) } + let b = right.split(separator: "/").compactMap { Int($0) } + return a.lexicographicallyPrecedes(b) == false && a != b + } + candidates += ["/Applications/ServBay/script/alias/php", "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"] + return candidates.first(where: { fm.isExecutableFile(atPath: $0) }) + } + static func hash(_ plaintext: String, phpPath: String) throws -> String { - let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"] - guard let executable = candidates.first(where: { - !$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0) - }) else { throw HashError.noPHP } + guard let executable = detectedPHPPath(manualPath: phpPath) else { throw HashError.noPHP } let process = Process() process.executableURL = URL(fileURLWithPath: executable) // Read the secret from stdin, never expose it in command-line arguments. - process.arguments = ["-r", "$p = stream_get_contents(STDIN); $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h) { fwrite(STDERR, 'bcrypt failed'); exit(1); } echo $h;"] + process.arguments = ["-r", "$p = stream_get_contents(STDIN); if ($p === false || $p === '') { fwrite(STDERR, 'Missing password'); exit(1); } $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h || !password_verify($p, $h)) { fwrite(STDERR, 'bcrypt self-verification failed'); exit(1); } echo $h;"] let input = Pipe() let output = Pipe() let errors = Pipe() @@ -7951,7 +7987,7 @@ private struct DBSynLaravelPasswordGenerator: View { let onUseHash: (String) -> Void @State private var plaintext = "" @State private var hashed = "" - @State private var phpPath = "" + @AppStorage("DBSyn.laravelPHPExecutable") private var phpPath = "" @State private var showPassword = false @State private var busy = false @State private var errorText: String? @@ -7979,7 +8015,7 @@ private struct DBSynLaravelPasswordGenerator: View { HStack { TextField("PHP executable path (optional)", text: $phpPath) .textFieldStyle(.roundedBorder) - Text("Uses Homebrew PHP automatically when available") + Text(LaravelBcryptService.detectedPHPPath(manualPath: phpPath).map { "PHP: " + $0 } ?? "PHP not detected — specify an executable") .font(.caption).foregroundStyle(.secondary) } HStack { @@ -7995,6 +8031,8 @@ private struct DBSynLaravelPasswordGenerator: View { if let errorText { Text(errorText).foregroundStyle(.red).textSelection(.enabled) } + Text("The generated hash is verified with PHP before it can be used. After saving, DBSyn checks the stored hash matches exactly.") + .font(.caption).foregroundStyle(.secondary) Text("Copy the plaintext password before closing. It cannot be recovered from its hash.") .font(.caption).foregroundStyle(.secondary) HStack { diff --git a/docs/DBSyn-1.0.6-build79-release-notes.md b/docs/DBSyn-1.0.6-build79-release-notes.md new file mode 100644 index 0000000..376b82c --- /dev/null +++ b/docs/DBSyn-1.0.6-build79-release-notes.md @@ -0,0 +1,7 @@ +# DBSyn 1.0.6 (Build 79) + +- Restore ServBay PHP auto-discovery (including versioned PHP binaries). +- Display detected PHP executable and remember manual override. +- Retain row editing and binary-safe optimistic comparisons from Build 78. + +Source inspection performed; macOS Xcode build and database integration tests not run. diff --git a/docs/DBSyn-1.0.7-build80-release-notes.md b/docs/DBSyn-1.0.7-build80-release-notes.md new file mode 100644 index 0000000..6457c74 --- /dev/null +++ b/docs/DBSyn-1.0.7-build80-release-notes.md @@ -0,0 +1,9 @@ +# DBSyn 1.0.7 — Build 80 + +- Row UPDATE SQL now matches rows using original primary-key columns only, removing the previous password comparison and its collation conflict. +- Ordinary text values are shown as readable quoted SQL literals with escaped single quotes; control characters and backslashes retain hexadecimal encoding for SQL-mode safety. +- Preserves Laravel bcrypt generator and ServBay PHP detection from Build 79. + +**Caution:** Primary-key-only updates do not detect concurrent edits to non-key columns. Check the SQL preview before applying. + +**Verification:** Static source inspection only. macOS Xcode compilation and live MySQL execution have not been performed. diff --git a/docs/DBSyn-1.0.8-build81-release-notes.md b/docs/DBSyn-1.0.8-build81-release-notes.md new file mode 100644 index 0000000..0b906df --- /dev/null +++ b/docs/DBSyn-1.0.8-build81-release-notes.md @@ -0,0 +1,9 @@ +# DBSyn 1.0.8 (Build 81) + +- Generate Laravel-compatible PHP bcrypt (cost 12) using stdin and verify with `password_verify` before returning a hash. +- After saving a generated hash, query MySQL for the saved value and compare it byte-for-byte with the generated hash. +- Report errors if the row is missing or the stored hash differs. +- Manual changes to the password field after generation invalidate read-back verification for that value. +- Version/build metadata updated. + +**Validation:** Static source review only; macOS Xcode compilation and database integration tests were not available in this environment.