row editing

This commit is contained in:
Mark Gregory
2026-10-20 09:36:41 +01:00
parent 233ff2bf64
commit 0a4cca2b41
5 changed files with 90 additions and 27 deletions
+4 -4
View File
@@ -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.";
+61 -23
View File
@@ -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 {
@@ -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.
@@ -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.
@@ -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.