Skip to content

Commit 1bdf5d0

Browse files
committed
fix(sidebar): read a linked SQL file's header in the encoding the whole file uses
1 parent 941fbea commit 1bdf5d0

9 files changed

Lines changed: 572 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8080
- Global saved query inside a folder missing from every other connection. (#3045)
8181
- Saved query and folder drawn nowhere when the folder holding it was gone.
8282
- Keyword accepted for a global saved query while another connection already held it.
83+
- 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.
8384
- Cleared keyword, folder or **Global** on a saved query or its folder never reaching another device.
8485
- Renaming a folder putting back the scope another window had just set.
8586
- Saved queries and their folders deleted at launch when their connection had not arrived from iCloud.

TablePro/Core/Utilities/File/FileTextLoader.swift

Lines changed: 18 additions & 8 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)
@@ -42,16 +45,23 @@ internal enum FileTextLoader {
4245
static func loadHeader(_ url: URL, maxBytes: Int = 4_096) -> LoadedText? {
4346
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
4447
defer { try? handle.close() }
45-
guard let data = try? handle.read(upToCount: maxBytes), !data.isEmpty else { return nil }
48+
guard let bytes = try? handle.read(upToCount: maxBytes + TextPrefixDecoder.lookaheadLength),
49+
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: maxBytes) else { return nil }
4650
let modifiedAt = modificationDate(of: url)
51+
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
52+
}
4753

48-
if let content = String(data: data, encoding: .utf8) {
49-
return LoadedText(content: content, encoding: .utf8, modifiedAt: modifiedAt)
50-
}
51-
if let content = String(data: data, encoding: .isoLatin1) {
52-
return LoadedText(content: content, encoding: .isoLatin1, modifiedAt: modifiedAt)
53-
}
54-
return nil
54+
private static func startsWithByteOrderMark(_ url: URL) -> Bool {
55+
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
56+
defer { try? handle.close() }
57+
guard let bytes = try? handle.read(upToCount: ByteOrderMark.longestLength) else { return false }
58+
return ByteOrderMark.leading(bytes) != nil
59+
}
60+
61+
private static func loadByteOrderMarked(_ url: URL, modifiedAt: Date?) -> LoadedText? {
62+
guard let bytes = try? Data(contentsOf: url),
63+
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: bytes.count) else { return nil }
64+
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
5565
}
5666
}
5767

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+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//
2+
// FileTextLoaderTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
@testable import TablePro
8+
import Testing
9+
10+
@Suite("File text loader")
11+
struct FileTextLoaderTests {
12+
private static let headerLength = 4_096
13+
private static let reportedName = "B\u{E1}o c\u{E1}o doanh thu"
14+
15+
private func withFile<T>(_ bytes: Data, _ body: (URL) -> T) throws -> T {
16+
let url = FileManager.default.temporaryDirectory
17+
.appendingPathComponent("FileTextLoaderTests-\(UUID().uuidString).sql")
18+
try bytes.write(to: url)
19+
defer { try? FileManager.default.removeItem(at: url) }
20+
return body(url)
21+
}
22+
23+
private func loadHeader(of bytes: Data) throws -> FileTextLoader.LoadedText? {
24+
try withFile(bytes) { FileTextLoader.loadHeader($0) }
25+
}
26+
27+
private func load(_ bytes: Data) throws -> FileTextLoader.LoadedText? {
28+
try withFile(bytes) { FileTextLoader.load($0) }
29+
}
30+
31+
private func utf8File(named name: String, straddling character: String, bytesInsideHeader: Int) -> Data {
32+
var bytes = Data("-- @name: \(name)\nSELECT '".utf8)
33+
let paddingLength = Self.headerLength - bytesInsideHeader - bytes.count
34+
bytes.append(Data(repeating: UInt8(ascii: "x"), count: paddingLength))
35+
bytes.append(Data(character.utf8))
36+
bytes.append(Data("';\n".utf8))
37+
return bytes
38+
}
39+
40+
@Test("A UTF-8 file with a character across the header limit keeps its name and encoding")
41+
func keepsUTF8WhenACharacterStraddlesTheLimit() throws {
42+
let bytes = utf8File(named: Self.reportedName, straddling: "\u{1EC7}", bytesInsideHeader: 2)
43+
#expect(bytes.count > Self.headerLength)
44+
#expect(String(data: bytes.prefix(Self.headerLength), encoding: .utf8) == nil)
45+
46+
let header = try #require(try loadHeader(of: bytes))
47+
48+
#expect(header.encoding == .utf8)
49+
#expect(SQLFrontmatter.parse(header.content).name == Self.reportedName)
50+
}
51+
52+
@Test("A genuinely Latin-1 file is still read as Latin-1")
53+
func readsALatin1FileAsLatin1() throws {
54+
let bytes = try #require("-- @name: Caf\u{E9} cr\u{E8}me\nSELECT 1;\n".data(using: .isoLatin1))
55+
56+
let header = try #require(try loadHeader(of: bytes))
57+
58+
#expect(header.encoding == .isoLatin1)
59+
#expect(SQLFrontmatter.parse(header.content).name == "Caf\u{E9} cr\u{E8}me")
60+
}
61+
62+
@Test("A UTF-16 file with a byte order mark is read as UTF-16")
63+
func readsAUTF16FileAsUTF16() throws {
64+
let text = try #require("-- @name: \(Self.reportedName)\nSELECT 1;\n".data(using: .utf16LittleEndian))
65+
66+
let header = try #require(try loadHeader(of: Data([0xFF, 0xFE]) + text))
67+
68+
#expect(header.encoding == .utf16)
69+
#expect(SQLFrontmatter.parse(header.content).name == Self.reportedName)
70+
}
71+
72+
@Test("A file shorter than the header limit is read whole")
73+
func readsAShortFileWhole() throws {
74+
let text = "-- @name: \(Self.reportedName)\nSELECT 1;\n"
75+
76+
let header = try #require(try loadHeader(of: Data(text.utf8)))
77+
78+
#expect(header.encoding == .utf8)
79+
#expect(header.content == text)
80+
}
81+
82+
@Test("A UTF-32 big-endian file loads as UTF-32")
83+
func loadsBigEndianUTF32() throws {
84+
let text = "-- @name: \(Self.reportedName)\nSELECT 1;\n"
85+
let bytes = try Data([0x00, 0x00, 0xFE, 0xFF]) + #require(text.data(using: .utf32BigEndian))
86+
87+
let loaded = try #require(try load(bytes))
88+
89+
#expect(loaded.encoding == .utf32)
90+
#expect(loaded.content == text)
91+
}
92+
93+
@Test("A byte-order-marked file cut partway through a code unit keeps every byte")
94+
func keepsEveryByteOfACutMarkedFile() throws {
95+
let utf16 = try Data([0xFF, 0xFE]) + #require("ab".data(using: .utf16LittleEndian)) + Data([0x41])
96+
let utf32 = try Data([0xFF, 0xFE, 0x00, 0x00]) + #require("ab".data(using: .utf32LittleEndian)).dropLast(2)
97+
98+
for bytes in [utf16, utf32] {
99+
let loaded = try #require(try load(bytes))
100+
#expect(loaded.encoding == .isoLatin1)
101+
#expect(loaded.content == String(data: bytes, encoding: .isoLatin1))
102+
}
103+
}
104+
105+
@Test("A byte order mark outranks a text encoding attribute that names another encoding")
106+
func byteOrderMarkOutranksTheEncodingAttribute() throws {
107+
let text = "-- @name: \(Self.reportedName)\n"
108+
let bytes = try Data([0xFF, 0xFE]) + #require(text.data(using: .utf16LittleEndian))
109+
let attribute = Array("MACINTOSH;0".utf8)
110+
111+
let result = try withFile(bytes) { url in
112+
(
113+
status: setxattr(url.path, "com.apple.TextEncoding", attribute, attribute.count, 0, 0),
114+
loaded: FileTextLoader.load(url)
115+
)
116+
}
117+
118+
#expect(result.status == 0)
119+
let loaded = try #require(result.loaded)
120+
#expect(loaded.encoding == .utf16)
121+
#expect(loaded.content == text)
122+
}
123+
124+
@Test("The header is the start of what loading the whole file reads, in the same encoding")
125+
func agreesWithFullLoading() throws {
126+
let text = "-- @name: \(Self.reportedName)\n"
127+
let latin1 = try #require("-- @name: Caf\u{E9}\nSELECT 1;\n".data(using: .isoLatin1))
128+
let utf16LittleEndian = try Data([0xFF, 0xFE]) + #require(text.data(using: .utf16LittleEndian))
129+
let utf16BigEndian = try Data([0xFE, 0xFF]) + #require(text.data(using: .utf16BigEndian))
130+
let utf32LittleEndian = try Data([0xFF, 0xFE, 0x00, 0x00]) + #require(text.data(using: .utf32LittleEndian))
131+
let utf32BigEndian = try Data([0x00, 0x00, 0xFE, 0xFF]) + #require(text.data(using: .utf32BigEndian))
132+
let files = [
133+
utf8File(named: Self.reportedName, straddling: "\u{E1}", bytesInsideHeader: 1),
134+
utf8File(named: Self.reportedName, straddling: "\u{1EC7}", bytesInsideHeader: 1),
135+
utf8File(named: Self.reportedName, straddling: "\u{1F600}", bytesInsideHeader: 3),
136+
latin1,
137+
utf16LittleEndian,
138+
utf16BigEndian,
139+
utf32LittleEndian,
140+
utf32BigEndian,
141+
utf16LittleEndian + Data([0x41]),
142+
utf32LittleEndian + Data([0x41, 0x00])
143+
]
144+
145+
for (index, bytes) in files.enumerated() {
146+
let loaded = try withFile(bytes) { url in
147+
(header: FileTextLoader.loadHeader(url), whole: FileTextLoader.load(url))
148+
}
149+
let header = try #require(loaded.header, "file \(index)")
150+
let whole = try #require(loaded.whole, "file \(index)")
151+
#expect(header.encoding == whole.encoding, "file \(index)")
152+
#expect(whole.content.unicodeScalars.starts(with: header.content.unicodeScalars), "file \(index)")
153+
}
154+
}
155+
156+
@Test("An empty file has no header")
157+
func returnsNothingForAnEmptyFile() throws {
158+
let header = try loadHeader(of: Data())
159+
160+
#expect(header == nil)
161+
}
162+
}

0 commit comments

Comments
 (0)