|
|
|
@@ -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 {
|
|
|
|
|