Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425)
- Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425)
- Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421)
- Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON.

### Changed

Expand Down
33 changes: 33 additions & 0 deletions TablePro/Core/Services/Formatting/CellValueContentDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// CellValueContentDetector.swift
// TablePro
//

import Foundation

internal enum CellValueContent: Equatable {
case json
case phpSerialized
case plain
}

internal enum CellValueContentDetector {
private static let sizeCapBytes = 5_000_000

static func detect(_ value: String) -> CellValueContent {
guard !value.isEmpty else { return .plain }
guard (value as NSString).length <= sizeCapBytes else { return .plain }

let first = value.unicodeScalars.first
if first == "{" || first == "[" {
if value.looksLikeJson { return .json }
}

let phpFirstScalars: Set<Unicode.Scalar> = ["N", "b", "i", "d", "s", "S", "a", "O", "C", "o", "r", "R"]
if let first, phpFirstScalars.contains(first) {
if PhpSerializeParser.looksLikePhpSerialized(value) { return .phpSerialized }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a successful PHP parse before routing

For editable text that merely starts with a PHP-ish prefix but is not serialized data (for example s: status or i: todo), this returns .phpSerialized because looksLikePhpSerialized only checks the first token and separator. CellInteractionResolver then opens the read-only PHP viewer for the cell, so double-click no longer starts the normal editor for those plain-text values.

Useful? React with 👍 / 👎.

}

return .plain
}
}
273 changes: 273 additions & 0 deletions TablePro/Core/Services/Formatting/PhpSerializeParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
//
// PhpSerializeParser.swift
// TablePro
//

import Foundation

internal indirect enum PhpValue: Equatable {
case null
case bool(Bool)
case int(Int)
case float(Double)
case string(String)
case array([PhpKeyValue])
case object(className: String, properties: [PhpProperty])
case serializable(className: String, rawPayload: String)
case reference(id: Int)
case unsupported(token: String)
case depthExceeded
case tooLarge

static func == (lhs: PhpValue, rhs: PhpValue) -> Bool {
switch (lhs, rhs) {
case (.null, .null): return true
case (.bool(let a), .bool(let b)): return a == b
case (.int(let a), .int(let b)): return a == b
case (.float(let a), .float(let b)):
if a.isNaN && b.isNaN { return true }
return a == b
case (.string(let a), .string(let b)): return a == b
case (.array(let a), .array(let b)): return a == b
case (.object(let aName, let aProps), .object(let bName, let bProps)):
return aName == bName && aProps == bProps
case (.serializable(let aName, let aPayload), .serializable(let bName, let bPayload)):
return aName == bName && aPayload == bPayload
case (.reference(let a), .reference(let b)): return a == b
case (.unsupported(let a), .unsupported(let b)): return a == b
case (.depthExceeded, .depthExceeded): return true
case (.tooLarge, .tooLarge): return true
default: return false
}
}
}

internal struct PhpKeyValue: Equatable {
let key: PhpValue
let value: PhpValue
}

internal struct PhpProperty: Equatable {
let name: String
let visibility: PhpVisibility
let value: PhpValue
}

internal enum PhpVisibility: Equatable {
case publicVisibility
case protectedVisibility
case privateVisibility(className: String)
}

internal enum PhpSerializeParser {
static let sizeCapBytes = 5_000_000
static let depthCap = 256

static func looksLikePhpSerialized(_ value: String) -> Bool {
guard !value.isEmpty else { return false }
guard (value as NSString).length <= sizeCapBytes else { return false }
guard let first = value.unicodeScalars.first else { return false }
let validFirst: Set<Unicode.Scalar> = ["N", "b", "i", "d", "s", "S", "a", "O", "C", "o", "r", "R"]
guard validFirst.contains(first) else { return false }
let bytes = Array(value.utf8)
guard bytes.count >= 2 else { return false }
if bytes[0] == UInt8(ascii: "N") {
return bytes[1] == UInt8(ascii: ";")
}
return bytes.count >= 3 && bytes[1] == UInt8(ascii: ":")
}

static func parse(_ value: String) -> PhpValue? {
guard !value.isEmpty else { return nil }
guard (value as NSString).length <= sizeCapBytes else { return nil }
var cursor = Cursor(bytes: Array(value.utf8))
guard let result = cursor.parseValue(depth: 0) else { return nil }
return result
Comment on lines +84 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject trailing bytes after the serialized value

When the input has a valid serialized prefix followed by additional data, such as i:1; trailing text, parse returns the prefix value and never checks that the cursor consumed the whole string. The PHP viewer will therefore present the cell as the integer 1, hiding the extra bytes from the tree view instead of treating the value as invalid/plain text.

Useful? React with 👍 / 👎.

}
}

private struct Cursor {
let bytes: [UInt8]
var index: Int = 0

var isAtEnd: Bool { index >= bytes.count }

mutating func parseValue(depth: Int) -> PhpValue? {
guard depth <= PhpSerializeParser.depthCap else { return .depthExceeded }
guard index < bytes.count else { return nil }
let token = bytes[index]
switch token {
case UInt8(ascii: "N"): return parseNull()
case UInt8(ascii: "b"): return parseBool()
case UInt8(ascii: "i"): return parseInt()
case UInt8(ascii: "d"): return parseFloat()
case UInt8(ascii: "s"), UInt8(ascii: "S"): return parseString(token: token)
case UInt8(ascii: "a"): return parseArray(depth: depth)
case UInt8(ascii: "O"): return parseObject(depth: depth)
case UInt8(ascii: "C"): return parseSerializable()
case UInt8(ascii: "o"):
return .unsupported(token: "o")
case UInt8(ascii: "r"), UInt8(ascii: "R"): return parseReference()
default: return nil
}
}

private mutating func expect(_ byte: UInt8) -> Bool {
guard index < bytes.count, bytes[index] == byte else { return false }
index += 1
return true
}

private mutating func parseNull() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ";")) else { return nil }
return .null
}

private mutating func parseBool() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")), index < bytes.count else { return nil }
let digit = bytes[index]
guard digit == UInt8(ascii: "0") || digit == UInt8(ascii: "1") else { return nil }
index += 1
guard expect(UInt8(ascii: ";")) else { return nil }
return .bool(digit == UInt8(ascii: "1"))
}

private mutating func parseInt() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let raw = readUntil(UInt8(ascii: ";")) else { return nil }
guard let value = Int(raw) else { return nil }
guard expect(UInt8(ascii: ";")) else { return nil }
return .int(value)
}

private mutating func parseFloat() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let raw = readUntil(UInt8(ascii: ";")) else { return nil }
guard expect(UInt8(ascii: ";")) else { return nil }
switch raw {
case "INF": return .float(.infinity)
case "-INF": return .float(-.infinity)
case "NAN": return .float(.nan)
default:
guard let value = Double(raw) else { return nil }
return .float(value)
}
}

private mutating func parseString(token: UInt8) -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let lengthRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let length = Int(lengthRaw), length >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil }
guard index + length <= bytes.count else { return nil }
guard let decoded = String(bytes: bytes[index..<(index + length)], encoding: .utf8) else { return nil }
index += length
guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ";")) else { return nil }
return .string(decoded)
}

private mutating func parseArray(depth: Int) -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let countRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let count = Int(countRaw), count >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil }

var entries: [PhpKeyValue] = []
entries.reserveCapacity(count)
Comment on lines +181 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reserving untrusted PHP array counts

A malformed cell like a:1000000000:{} is under the 5 MB input cap but causes reserveCapacity(count) to try allocating space for a billion entries before the parser discovers the body is invalid; the object-property path has the same pattern. Opening or auto-rendering such a value can crash the app from memory pressure, so the count should be bounded by the remaining payload or a parser cap before reserving.

Useful? React with 👍 / 👎.

for _ in 0..<count {
guard let key = parseValue(depth: depth + 1) else { return nil }
guard let value = parseValue(depth: depth + 1) else { return nil }
entries.append(PhpKeyValue(key: key, value: value))
}
guard expect(UInt8(ascii: "}")) else { return nil }
return .array(entries)
}

private mutating func parseObject(depth: Int) -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let nameLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let nameLength = Int(nameLengthRaw), nameLength >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil }
guard index + nameLength <= bytes.count else { return nil }
guard let className = String(bytes: bytes[index..<(index + nameLength)], encoding: .utf8) else { return nil }
index += nameLength
guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ":")) else { return nil }
guard let countRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let count = Int(countRaw), count >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil }

var properties: [PhpProperty] = []
properties.reserveCapacity(count)
for _ in 0..<count {
guard case let .string(rawKey)? = parseValue(depth: depth + 1) else { return nil }
let decoded = decodePropertyKey(rawKey)
guard let value = parseValue(depth: depth + 1) else { return nil }
properties.append(PhpProperty(name: decoded.name, visibility: decoded.visibility, value: value))
}
guard expect(UInt8(ascii: "}")) else { return nil }
return .object(className: className, properties: properties)
}

private mutating func parseSerializable() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let nameLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let nameLength = Int(nameLengthRaw), nameLength >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "\"")) else { return nil }
guard index + nameLength <= bytes.count else { return nil }
guard let className = String(bytes: bytes[index..<(index + nameLength)], encoding: .utf8) else { return nil }
index += nameLength
guard expect(UInt8(ascii: "\"")), expect(UInt8(ascii: ":")) else { return nil }
guard let payloadLengthRaw = readUntil(UInt8(ascii: ":")) else { return nil }
guard let payloadLength = Int(payloadLengthRaw), payloadLength >= 0 else { return nil }
guard expect(UInt8(ascii: ":")), expect(UInt8(ascii: "{")) else { return nil }
guard index + payloadLength <= bytes.count else { return nil }
let payload = String(bytes: bytes[index..<(index + payloadLength)], encoding: .utf8) ?? ""
index += payloadLength
guard expect(UInt8(ascii: "}")) else { return nil }
return .serializable(className: className, rawPayload: payload)
}

private mutating func parseReference() -> PhpValue? {
index += 1
guard expect(UInt8(ascii: ":")) else { return nil }
guard let raw = readUntil(UInt8(ascii: ";")) else { return nil }
guard let value = Int(raw) else { return nil }
guard expect(UInt8(ascii: ";")) else { return nil }
return .reference(id: value)
}

private mutating func readUntil(_ terminator: UInt8) -> String? {
let start = index
while index < bytes.count, bytes[index] != terminator {
index += 1
}
guard index < bytes.count else { return nil }
return String(bytes: bytes[start..<index], encoding: .utf8)
}
}

private func decodePropertyKey(_ raw: String) -> (name: String, visibility: PhpVisibility) {
let scalars = Array(raw.unicodeScalars)
guard scalars.first?.value == 0 else {
return (raw, .publicVisibility)
}
let secondNullIndex = scalars.dropFirst().firstIndex { $0.value == 0 }
guard let nullIndex = secondNullIndex, nullIndex < scalars.count - 1 else {
return (raw, .publicVisibility)
}
let middle = String(String.UnicodeScalarView(scalars[1..<nullIndex]))
let nameScalars = scalars[(nullIndex + 1)...]
let name = String(String.UnicodeScalarView(nameScalars))
if middle == "*" {
return (name, .protectedVisibility)
}
return (name, .privateVisibility(className: middle))
}
6 changes: 6 additions & 0 deletions TablePro/Core/Services/Formatting/ValueDisplayFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enum ValueDisplayFormat: String, Codable, CaseIterable, Identifiable {
case uuid
case unixTimestamp
case unixTimestampMillis
case json
case phpSerialized

var id: String { rawValue }

Expand All @@ -23,6 +25,8 @@ enum ValueDisplayFormat: String, Codable, CaseIterable, Identifiable {
case .uuid: return String(localized: "UUID")
case .unixTimestamp: return String(localized: "Unix Timestamp (seconds)")
case .unixTimestampMillis: return String(localized: "Unix Timestamp (milliseconds)")
case .json: return String(localized: "JSON")
case .phpSerialized: return String(localized: "PHP Serialized")
}
}

Expand All @@ -35,6 +39,8 @@ enum ValueDisplayFormat: String, Codable, CaseIterable, Identifiable {
return ["blob", "text"]
case .unixTimestamp, .unixTimestampMillis:
return ["integer"]
case .json, .phpSerialized:
return ["text"]
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final class ValueDisplayFormatService {
return formatAsTimestamp(rawValue, divideBy: 1)
case .unixTimestampMillis:
return formatAsTimestamp(rawValue, divideBy: 1_000)
case .json, .phpSerialized:
return rawValue
}
}

Expand Down
Loading
Loading