Skip to content

Commit 1dda5ca

Browse files
authored
fix(sidebar): read a linked SQL file's header in the encoding the whole file uses (#3083)
1 parent 4e7b8ae commit 1dda5ca

10 files changed

Lines changed: 582 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
100100
- Global saved query inside a folder missing from every other connection. (#3045)
101101
- Saved query and folder drawn nowhere when the folder holding it was gone.
102102
- Keyword accepted for a global saved query while another connection already held it.
103+
- Garbled name and ISO-8859-1 label on a UTF-8, UTF-16 or UTF-32 linked SQL file, and garbled big-endian UTF-32 files.
103104
- Cleared keyword, folder or **Global** on a saved query or its folder never reaching another device.
104105
- Renaming a folder putting back the scope another window had just set.
105106
- Saved queries and their folders deleted at launch when their connection had not arrived from iCloud.

TablePro/Core/Utilities/File/FileTextLoader.swift

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ internal enum FileTextLoader {
2222

2323
static func load(_ url: URL) -> LoadedText? {
2424
let modifiedAt = modificationDate(of: url)
25+
if startsWithByteOrderMark(url) {
26+
return loadByteOrderMarked(url, modifiedAt: modifiedAt)
27+
}
2528
var detected: String.Encoding = .utf8
2629
if let content = try? String(contentsOf: url, usedEncoding: &detected) {
2730
return LoadedText(content: content, encoding: detected, modifiedAt: modifiedAt)
@@ -36,20 +39,8 @@ internal enum FileTextLoader {
3639
}
3740

3841
static func decode(_ data: Data) -> String? {
39-
let utf8ByteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
40-
if data.starts(with: utf8ByteOrderMark) {
41-
return String(data: data.dropFirst(utf8ByteOrderMark.count), encoding: .utf8)
42-
}
43-
if data.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || data.starts(with: [0x00, 0x00, 0xFE, 0xFF]) {
44-
return String(data: data, encoding: .utf32)
45-
}
46-
if data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) {
47-
return String(data: data, encoding: .utf16)
48-
}
49-
if let content = String(data: data, encoding: .utf8) {
50-
return content
51-
}
52-
return String(data: data, encoding: .isoLatin1)
42+
guard !data.isEmpty else { return "" }
43+
return TextPrefixDecoder.decode(data, prefixLength: data.count)?.content
5344
}
5445

5546
static func modificationDate(of url: URL) -> Date? {
@@ -59,16 +50,23 @@ internal enum FileTextLoader {
5950
static func loadHeader(_ url: URL, maxBytes: Int = 4_096) -> LoadedText? {
6051
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
6152
defer { try? handle.close() }
62-
guard let data = try? handle.read(upToCount: maxBytes), !data.isEmpty else { return nil }
53+
guard let bytes = try? handle.read(upToCount: maxBytes + TextPrefixDecoder.lookaheadLength),
54+
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: maxBytes) else { return nil }
6355
let modifiedAt = modificationDate(of: url)
56+
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
57+
}
6458

65-
if let content = String(data: data, encoding: .utf8) {
66-
return LoadedText(content: content, encoding: .utf8, modifiedAt: modifiedAt)
67-
}
68-
if let content = String(data: data, encoding: .isoLatin1) {
69-
return LoadedText(content: content, encoding: .isoLatin1, modifiedAt: modifiedAt)
70-
}
71-
return nil
59+
private static func startsWithByteOrderMark(_ url: URL) -> Bool {
60+
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
61+
defer { try? handle.close() }
62+
guard let bytes = try? handle.read(upToCount: ByteOrderMark.longestLength) else { return false }
63+
return ByteOrderMark.leading(bytes) != nil
64+
}
65+
66+
private static func loadByteOrderMarked(_ url: URL, modifiedAt: Date?) -> LoadedText? {
67+
guard let bytes = try? Data(contentsOf: url),
68+
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: bytes.count) else { return nil }
69+
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
7270
}
7371
}
7472

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// TextPrefixDecoder.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal enum TextPrefixDecoder {
9+
struct Decoded {
10+
let content: String
11+
let encoding: String.Encoding
12+
}
13+
14+
static let lookaheadLength = 3
15+
16+
static func decode(_ bytes: Data, prefixLength: Int) -> Decoded? {
17+
guard !bytes.isEmpty, prefixLength > 0 else { return nil }
18+
let mark = ByteOrderMark.leading(bytes)
19+
let encoding = mark?.encoding ?? .utf8
20+
let codeUnitLength = mark?.codeUnitLength ?? 1
21+
if let content = decode(bytes, prefixLength: prefixLength, as: encoding, codeUnitLength: codeUnitLength) {
22+
return Decoded(content: content, encoding: encoding)
23+
}
24+
guard let content = String(data: bytes.prefix(prefixLength), encoding: .isoLatin1) else { return nil }
25+
return Decoded(content: content, encoding: .isoLatin1)
26+
}
27+
28+
private static func decode(
29+
_ bytes: Data,
30+
prefixLength: Int,
31+
as encoding: String.Encoding,
32+
codeUnitLength: Int
33+
) -> String? {
34+
for end in candidateEnds(of: bytes, prefixLength: prefixLength) where end.isMultiple(of: codeUnitLength) {
35+
if let content = String(data: bytes.prefix(end), encoding: encoding) {
36+
return content
37+
}
38+
}
39+
return nil
40+
}
41+
42+
private static func candidateEnds(of bytes: Data, prefixLength: Int) -> ClosedRange<Int> {
43+
guard bytes.count > prefixLength else { return bytes.count...bytes.count }
44+
return prefixLength...min(bytes.count, prefixLength + lookaheadLength)
45+
}
46+
}

TablePro/Core/Utilities/SQL/SQLChunkDecoder.swift

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -92,40 +92,22 @@ struct SQLChunkDecoder {
9292
let byteOrderMarkLength: Int
9393
}
9494

95-
private static let utf16LittleEndianMark: [UInt8] = [0xFF, 0xFE]
96-
private static let utf16BigEndianMark: [UInt8] = [0xFE, 0xFF]
97-
private static let utf32LittleEndianMark: [UInt8] = [0xFF, 0xFE, 0x00, 0x00]
98-
private static let utf32BigEndianMark: [UInt8] = [0x00, 0x00, 0xFE, 0xFF]
99-
10095
/// `.utf8` is absent on purpose: Foundation consumes a UTF-8 mark itself, measured.
10196
private static func resolve(_ encoding: String.Encoding, startingWith data: Data) -> Resolution {
97+
if let mark = ByteOrderMark.leading(data, allowedBy: encoding) {
98+
return Resolution(encoding: mark.byteOrderedEncoding, byteOrderMarkLength: mark.length)
99+
}
100+
return Resolution(encoding: unmarkedByteOrder(of: encoding), byteOrderMarkLength: 0)
101+
}
102+
103+
private static func unmarkedByteOrder(of encoding: String.Encoding) -> String.Encoding {
102104
switch encoding {
103105
case .utf16:
104-
if data.starts(with: utf16LittleEndianMark) {
105-
return Resolution(encoding: .utf16LittleEndian, byteOrderMarkLength: 2)
106-
}
107-
if data.starts(with: utf16BigEndianMark) {
108-
return Resolution(encoding: .utf16BigEndian, byteOrderMarkLength: 2)
109-
}
110-
return Resolution(encoding: .utf16BigEndian, byteOrderMarkLength: 0)
111-
case .utf16LittleEndian:
112-
return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf16LittleEndianMark) ? 2 : 0)
113-
case .utf16BigEndian:
114-
return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf16BigEndianMark) ? 2 : 0)
106+
return .utf16BigEndian
115107
case .utf32:
116-
if data.starts(with: utf32LittleEndianMark) {
117-
return Resolution(encoding: .utf32LittleEndian, byteOrderMarkLength: 4)
118-
}
119-
if data.starts(with: utf32BigEndianMark) {
120-
return Resolution(encoding: .utf32BigEndian, byteOrderMarkLength: 4)
121-
}
122-
return Resolution(encoding: .utf32BigEndian, byteOrderMarkLength: 0)
123-
case .utf32LittleEndian:
124-
return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf32LittleEndianMark) ? 4 : 0)
125-
case .utf32BigEndian:
126-
return Resolution(encoding: encoding, byteOrderMarkLength: data.starts(with: utf32BigEndianMark) ? 4 : 0)
108+
return .utf32BigEndian
127109
default:
128-
return Resolution(encoding: encoding, byteOrderMarkLength: 0)
110+
return encoding
129111
}
130112
}
131113

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//
2+
// ByteOrderMark.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal enum ByteOrderMark {
9+
case utf32LittleEndian
10+
case utf32BigEndian
11+
case utf16LittleEndian
12+
case utf16BigEndian
13+
14+
private static let longestFirst: [ByteOrderMark] = [
15+
.utf32LittleEndian, .utf32BigEndian, .utf16LittleEndian, .utf16BigEndian
16+
]
17+
18+
static var longestLength: Int { longestFirst.map(\.length).max() ?? 0 }
19+
20+
static func leading(_ bytes: Data) -> ByteOrderMark? {
21+
longestFirst.first { bytes.starts(with: $0.bytes) }
22+
}
23+
24+
static func leading(_ bytes: Data, allowedBy declaredEncoding: String.Encoding) -> ByteOrderMark? {
25+
longestFirst.first { $0.isAllowed(by: declaredEncoding) && bytes.starts(with: $0.bytes) }
26+
}
27+
28+
var bytes: [UInt8] {
29+
switch self {
30+
case .utf32LittleEndian: return [0xFF, 0xFE, 0x00, 0x00]
31+
case .utf32BigEndian: return [0x00, 0x00, 0xFE, 0xFF]
32+
case .utf16LittleEndian: return [0xFF, 0xFE]
33+
case .utf16BigEndian: return [0xFE, 0xFF]
34+
}
35+
}
36+
37+
var length: Int { bytes.count }
38+
39+
var encoding: String.Encoding {
40+
switch self {
41+
case .utf32LittleEndian, .utf32BigEndian: return .utf32
42+
case .utf16LittleEndian, .utf16BigEndian: return .utf16
43+
}
44+
}
45+
46+
var byteOrderedEncoding: String.Encoding {
47+
switch self {
48+
case .utf32LittleEndian: return .utf32LittleEndian
49+
case .utf32BigEndian: return .utf32BigEndian
50+
case .utf16LittleEndian: return .utf16LittleEndian
51+
case .utf16BigEndian: return .utf16BigEndian
52+
}
53+
}
54+
55+
var codeUnitLength: Int {
56+
switch self {
57+
case .utf32LittleEndian, .utf32BigEndian: return 4
58+
case .utf16LittleEndian, .utf16BigEndian: return 2
59+
}
60+
}
61+
62+
private func isAllowed(by declaredEncoding: String.Encoding) -> Bool {
63+
declaredEncoding == encoding || declaredEncoding == byteOrderedEncoding
64+
}
65+
}

TableProTests/Core/Git/GitOutputParserTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,12 @@ struct FileTextLoaderDecodeTests {
299299
#expect(FileTextLoader.decode(Data(text.utf8)) == text)
300300
#expect(FileTextLoader.decode(Data([0x53, 0xE9])) == "")
301301
}
302+
303+
@Test("A big-endian UTF-32 blob and an empty blob decode")
304+
func bigEndianAndEmpty() throws {
305+
let text = "SELECT 'é';"
306+
let utf32 = try #require(text.data(using: .utf32BigEndian))
307+
#expect(FileTextLoader.decode(Data([0x00, 0x00, 0xFE, 0xFF]) + utf32) == text)
308+
#expect(FileTextLoader.decode(Data()) == "")
309+
}
302310
}

0 commit comments

Comments
 (0)