imsg-grep 0.1.2-darwin
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/bin/img2png +0 -0
- data/bin/imsg-grep +749 -0
- data/bin/msg-info +133 -0
- data/bin/sql-shell +25 -0
- data/doc/HELP +181 -0
- data/doc/HELP_DATES +72 -0
- data/ext/extconf.rb +97 -0
- data/ext/img2png.swift +325 -0
- data/lib/imsg-grep/VERSION +1 -0
- data/lib/imsg-grep/apple/attr_str.rb +65 -0
- data/lib/imsg-grep/apple/bplist.rb +257 -0
- data/lib/imsg-grep/apple/keyed_archive.rb +105 -0
- data/lib/imsg-grep/dev/print_query.rb +84 -0
- data/lib/imsg-grep/dev/timer.rb +38 -0
- data/lib/imsg-grep/images/imaginator.rb +135 -0
- data/lib/imsg-grep/images/img2png.dylib +0 -0
- data/lib/imsg-grep/images/img2png.rb +84 -0
- data/lib/imsg-grep/messages.rb +314 -0
- data/lib/imsg-grep/utils/date.rb +117 -0
- data/lib/imsg-grep/utils/strop_utils.rb +79 -0
- metadata +161 -0
data/ext/img2png.swift
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Image to PNG conversion - both CLI and library versions
|
|
2
|
+
// Applies rotation metadata to actual pixels, strips orientation from output.
|
|
3
|
+
// Optional: resample to fit dimensions, place in centered box with black background.
|
|
4
|
+
//
|
|
5
|
+
// CLI Usage: ./img2png < input.jpg > output.png
|
|
6
|
+
// ./img2png --fit 800x600 < input.jpg > output.png
|
|
7
|
+
// ./img2png --fit 800x600 --box 1024x768 < input.jpg > output.png
|
|
8
|
+
// ./img2png --info < input.jpg # prints WxH to stdout
|
|
9
|
+
//
|
|
10
|
+
// Build CLI: swiftc -O -whole-module-optimization -lto=llvm-full -o bin/img2png ext/img2png.swift
|
|
11
|
+
// Build dylib: swiftc -O -whole-module-optimization -lto=llvm-full -emit-library -D LIBRARY -o lib/imsg-grep/images/img2png.dylib ext/img2png.swift
|
|
12
|
+
|
|
13
|
+
import Foundation
|
|
14
|
+
import ImageIO
|
|
15
|
+
import UniformTypeIdentifiers
|
|
16
|
+
|
|
17
|
+
let VERSION = "1.0.0"
|
|
18
|
+
|
|
19
|
+
// Shared image processing functions
|
|
20
|
+
|
|
21
|
+
func loadImage(from data: Data) -> (CGImage, Int, Int)? {
|
|
22
|
+
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
|
23
|
+
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let orientation = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
|
|
28
|
+
.flatMap { $0 as? [String: Any] }
|
|
29
|
+
.flatMap { $0[kCGImagePropertyOrientation as String] as? UInt32 }
|
|
30
|
+
.flatMap { CGImagePropertyOrientation(rawValue: $0) } ?? .up
|
|
31
|
+
|
|
32
|
+
let rotatedWidth = orientation.rawValue > 4 ? image.height : image.width
|
|
33
|
+
let rotatedHeight = orientation.rawValue > 4 ? image.width : image.height
|
|
34
|
+
|
|
35
|
+
guard let rotateContext = CGContext(data: nil, width: rotatedWidth, height: rotatedHeight,
|
|
36
|
+
bitsPerComponent: 8, bytesPerRow: 0,
|
|
37
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
38
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
|
|
39
|
+
return nil
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
rotateContext.interpolationQuality = .high
|
|
43
|
+
|
|
44
|
+
let w = CGFloat(rotatedWidth)
|
|
45
|
+
let h = CGFloat(rotatedHeight)
|
|
46
|
+
|
|
47
|
+
let transforms: [CGImagePropertyOrientation: (CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)] = [
|
|
48
|
+
.down: (w, h, 1, 1, .pi),
|
|
49
|
+
.downMirrored: (w, h, -1, 1, .pi),
|
|
50
|
+
.left: (w, 0, 1, 1, .pi / 2),
|
|
51
|
+
.leftMirrored: (w, 0, -1, 1, .pi / 2),
|
|
52
|
+
.right: (0, h, 1, 1, -.pi / 2),
|
|
53
|
+
.rightMirrored: (0, h, -1, 1, -.pi / 2),
|
|
54
|
+
.upMirrored: (w, 0, -1, 1, 0),
|
|
55
|
+
]
|
|
56
|
+
let (tx, ty, sx, sy, rot) = transforms[orientation] ?? (0, 0, 1, 1, 0)
|
|
57
|
+
|
|
58
|
+
if tx != 0 || ty != 0 { rotateContext.translateBy(x: tx, y: ty) }
|
|
59
|
+
if sx != 1 || sy != 1 { rotateContext.scaleBy(x: sx, y: sy) }
|
|
60
|
+
if rot != 0 { rotateContext.rotate(by: rot) }
|
|
61
|
+
|
|
62
|
+
rotateContext.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
|
63
|
+
|
|
64
|
+
guard let rotatedImage = rotateContext.makeImage() else {
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (rotatedImage, rotatedWidth, rotatedHeight)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func emitPNG(image: CGImage, fitW: Int = 0, fitH: Int = 0, boxW: Int = 0, boxH: Int = 0) -> Data? {
|
|
72
|
+
var finalImage = image
|
|
73
|
+
var finalWidth = image.width
|
|
74
|
+
var finalHeight = image.height
|
|
75
|
+
|
|
76
|
+
// Fit if requested
|
|
77
|
+
if fitW > 0 && fitH > 0 {
|
|
78
|
+
let scale = min(Double(fitW) / Double(image.width), Double(fitH) / Double(image.height))
|
|
79
|
+
let scaledW = Int(Double(image.width) * scale)
|
|
80
|
+
let scaledH = Int(Double(image.height) * scale)
|
|
81
|
+
|
|
82
|
+
guard let fitContext = CGContext(data: nil, width: scaledW, height: scaledH,
|
|
83
|
+
bitsPerComponent: 8, bytesPerRow: 0,
|
|
84
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
85
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fitContext.interpolationQuality = .high
|
|
90
|
+
fitContext.draw(image, in: CGRect(x: 0, y: 0, width: scaledW, height: scaledH))
|
|
91
|
+
|
|
92
|
+
guard let fittedImage = fitContext.makeImage() else {
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
finalImage = fittedImage
|
|
97
|
+
finalWidth = scaledW
|
|
98
|
+
finalHeight = scaledH
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Box if requested
|
|
102
|
+
if boxW > 0 && boxH > 0 {
|
|
103
|
+
guard let boxContext = CGContext(data: nil, width: boxW, height: boxH,
|
|
104
|
+
bitsPerComponent: 8, bytesPerRow: 0,
|
|
105
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
106
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
boxContext.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
|
|
111
|
+
boxContext.fill(CGRect(x: 0, y: 0, width: boxW, height: boxH))
|
|
112
|
+
|
|
113
|
+
let x = (boxW - finalWidth) / 2
|
|
114
|
+
let y = (boxH - finalHeight) / 2
|
|
115
|
+
boxContext.draw(finalImage, in: CGRect(x: x, y: y, width: finalWidth, height: finalHeight))
|
|
116
|
+
|
|
117
|
+
guard let boxedImage = boxContext.makeImage() else {
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
finalImage = boxedImage
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let outputData = NSMutableData()
|
|
125
|
+
guard let destination = CGImageDestinationCreateWithData(outputData, UTType.png.identifier as CFString, 1, nil) else {
|
|
126
|
+
return nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let options: [CFString: Any] = [kCGImagePropertyPNGCompressionFilter: 9]
|
|
130
|
+
CGImageDestinationAddImage(destination, finalImage, options as CFDictionary)
|
|
131
|
+
|
|
132
|
+
guard CGImageDestinationFinalize(destination) else {
|
|
133
|
+
return nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return outputData as Data
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// MARK: - Library API
|
|
140
|
+
|
|
141
|
+
// Opaque image handle
|
|
142
|
+
public class ImageHandle {
|
|
143
|
+
let image: CGImage
|
|
144
|
+
let width: Int
|
|
145
|
+
let height: Int
|
|
146
|
+
|
|
147
|
+
init(image: CGImage, width: Int, height: Int) {
|
|
148
|
+
self.image = image
|
|
149
|
+
self.width = width
|
|
150
|
+
self.height = height
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Load image from file path, apply rotation. Returns opaque handle or null on error.
|
|
155
|
+
@_cdecl("img2png_load_path")
|
|
156
|
+
public func img2png_load_path(path: UnsafePointer<CChar>,
|
|
157
|
+
outW: UnsafeMutablePointer<Int>,
|
|
158
|
+
outH: UnsafeMutablePointer<Int>) -> UnsafeMutableRawPointer? {
|
|
159
|
+
let pathStr = String(cString: path)
|
|
160
|
+
let url = URL(fileURLWithPath: pathStr)
|
|
161
|
+
|
|
162
|
+
guard let data = try? Data(contentsOf: url) else {
|
|
163
|
+
return nil
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return img2png_load(inputData: data.withUnsafeBytes { $0.bindMemory(to: UInt8.self).baseAddress! },
|
|
167
|
+
inputLen: data.count, outW: outW, outH: outH)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Load image from data, apply rotation. Returns opaque handle or null on error.
|
|
171
|
+
@_cdecl("img2png_load")
|
|
172
|
+
public func img2png_load(inputData: UnsafePointer<UInt8>, inputLen: Int,
|
|
173
|
+
outW: UnsafeMutablePointer<Int>,
|
|
174
|
+
outH: UnsafeMutablePointer<Int>) -> UnsafeMutableRawPointer? {
|
|
175
|
+
let data = Data(bytes: inputData, count: inputLen)
|
|
176
|
+
|
|
177
|
+
guard let (rotatedImage, rotatedWidth, rotatedHeight) = loadImage(from: data) else {
|
|
178
|
+
return nil
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let handle = ImageHandle(image: rotatedImage, width: rotatedWidth, height: rotatedHeight)
|
|
182
|
+
outW.pointee = rotatedWidth
|
|
183
|
+
outH.pointee = rotatedHeight
|
|
184
|
+
return Unmanaged.passRetained(handle).toOpaque()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Convert to PNG with optional fitting and boxing.
|
|
188
|
+
// fitW, fitH: scale to fit within these dimensions (0 = skip)
|
|
189
|
+
// boxW, boxH: place in centered box with black background (0 = skip)
|
|
190
|
+
// Returns PNG data and length via out parameters. Caller must free data.
|
|
191
|
+
@_cdecl("img2png_convert")
|
|
192
|
+
public func img2png_convert(
|
|
193
|
+
handle: UnsafeMutableRawPointer,
|
|
194
|
+
fitW: Int, fitH: Int,
|
|
195
|
+
boxW: Int, boxH: Int,
|
|
196
|
+
outData: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>,
|
|
197
|
+
outLen: UnsafeMutablePointer<Int>
|
|
198
|
+
) -> Bool {
|
|
199
|
+
let img = Unmanaged<ImageHandle>.fromOpaque(handle).takeUnretainedValue()
|
|
200
|
+
|
|
201
|
+
guard let pngData = emitPNG(image: img.image, fitW: fitW, fitH: fitH, boxW: boxW, boxH: boxH) else {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let bytes = malloc(pngData.count)!
|
|
206
|
+
_ = pngData.withUnsafeBytes { memcpy(bytes, $0.baseAddress!, pngData.count) }
|
|
207
|
+
|
|
208
|
+
outData.pointee = bytes.assumingMemoryBound(to: UInt8.self)
|
|
209
|
+
outLen.pointee = pngData.count
|
|
210
|
+
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Release image handle
|
|
215
|
+
@_cdecl("img2png_release")
|
|
216
|
+
public func img2png_release(handle: UnsafeMutableRawPointer) {
|
|
217
|
+
Unmanaged<ImageHandle>.fromOpaque(handle).release()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Free memory allocated by library
|
|
221
|
+
@_cdecl("img2png_free")
|
|
222
|
+
public func img2png_free(ptr: UnsafeMutableRawPointer?) {
|
|
223
|
+
free(ptr)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// MARK: - CLI Main
|
|
227
|
+
|
|
228
|
+
func parseDimensions(_ str: String) -> (Int, Int)? {
|
|
229
|
+
let parts = str.split(separator: "x")
|
|
230
|
+
guard parts.count == 2,
|
|
231
|
+
let w = Int(parts[0]),
|
|
232
|
+
let h = Int(parts[1]) else { return nil }
|
|
233
|
+
return (w, h)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#if !LIBRARY
|
|
237
|
+
@main
|
|
238
|
+
struct CLI {
|
|
239
|
+
static func main() {
|
|
240
|
+
// Parse arguments
|
|
241
|
+
var fit: (Int, Int)?
|
|
242
|
+
var box: (Int, Int)?
|
|
243
|
+
var infoMode = false
|
|
244
|
+
var i = 1
|
|
245
|
+
|
|
246
|
+
while i < CommandLine.arguments.count {
|
|
247
|
+
switch CommandLine.arguments[i] {
|
|
248
|
+
case "--version":
|
|
249
|
+
print(VERSION)
|
|
250
|
+
exit(0)
|
|
251
|
+
case "--fit":
|
|
252
|
+
guard i + 1 < CommandLine.arguments.count,
|
|
253
|
+
let dims = parseDimensions(CommandLine.arguments[i + 1]) else {
|
|
254
|
+
fputs("Invalid --fit WxH\n", stderr)
|
|
255
|
+
exit(1)
|
|
256
|
+
}
|
|
257
|
+
fit = dims
|
|
258
|
+
i += 2
|
|
259
|
+
case "--box":
|
|
260
|
+
guard i + 1 < CommandLine.arguments.count,
|
|
261
|
+
let dims = parseDimensions(CommandLine.arguments[i + 1]) else {
|
|
262
|
+
fputs("Invalid --box WxH\n", stderr)
|
|
263
|
+
exit(1)
|
|
264
|
+
}
|
|
265
|
+
box = dims
|
|
266
|
+
i += 2
|
|
267
|
+
case "--info":
|
|
268
|
+
infoMode = true
|
|
269
|
+
i += 1
|
|
270
|
+
default:
|
|
271
|
+
fputs("Unknown option: \(CommandLine.arguments[i])\n", stderr)
|
|
272
|
+
exit(1)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Read entire stdin into memory
|
|
277
|
+
let inputData = FileHandle.standardInput.readDataToEndOfFile()
|
|
278
|
+
|
|
279
|
+
guard !inputData.isEmpty else {
|
|
280
|
+
if CommandLine.arguments.count == 1 {
|
|
281
|
+
print("Usage: img2png [OPTIONS] < input > output")
|
|
282
|
+
print("")
|
|
283
|
+
print("Options:")
|
|
284
|
+
print(" --fit WxH Scale to fit within dimensions")
|
|
285
|
+
print(" --box WxH Place in centered box with black background")
|
|
286
|
+
print(" --info Print dimensions only")
|
|
287
|
+
print(" --version Print version")
|
|
288
|
+
print("")
|
|
289
|
+
print("Examples:")
|
|
290
|
+
print(" img2png < input.jpg > output.png")
|
|
291
|
+
print(" img2png --fit 800x600 < input.jpg > output.png")
|
|
292
|
+
print(" img2png --fit 800x600 --box 1024x768 < input.jpg > output.png")
|
|
293
|
+
exit(0)
|
|
294
|
+
} else {
|
|
295
|
+
fputs("No input data\n", stderr)
|
|
296
|
+
exit(1)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
guard let (rotatedImage, rotatedWidth, rotatedHeight) = loadImage(from: inputData) else {
|
|
301
|
+
fputs("Failed to load image\n", stderr)
|
|
302
|
+
exit(1)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// If info mode, print dimensions and exit
|
|
306
|
+
if infoMode {
|
|
307
|
+
print("\(rotatedWidth)x\(rotatedHeight)")
|
|
308
|
+
exit(0)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let fitW = fit?.0 ?? 0
|
|
312
|
+
let fitH = fit?.1 ?? 0
|
|
313
|
+
let boxW = box?.0 ?? 0
|
|
314
|
+
let boxH = box?.1 ?? 0
|
|
315
|
+
|
|
316
|
+
guard let pngData = emitPNG(image: rotatedImage, fitW: fitW, fitH: fitH, boxW: boxW, boxH: boxH) else {
|
|
317
|
+
fputs("Failed to convert to PNG\n", stderr)
|
|
318
|
+
exit(1)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Write to stdout
|
|
322
|
+
FileHandle.standardOutput.write(pngData)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
#endif
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.2
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Extract NSString value from unkeyed-archived (typedstream) NSAttributedString
|
|
5
|
+
|
|
6
|
+
class AttributedStringExtractor
|
|
7
|
+
def self.extract(data) = (new(data).extract if data)
|
|
8
|
+
|
|
9
|
+
TAG_INTEGER_2 = -127 # Indicates 2-byte integer follows
|
|
10
|
+
TAG_INTEGER_4 = -126 # Indicates 4-byte integer follows
|
|
11
|
+
|
|
12
|
+
def initialize(data)
|
|
13
|
+
return if data.nil?
|
|
14
|
+
# Find NSString position first and fail fast if not found
|
|
15
|
+
@nsstring_pos = data.index("NSString") or raise "NSString not found in data"
|
|
16
|
+
@data = data.b
|
|
17
|
+
@pos = 0
|
|
18
|
+
|
|
19
|
+
# Read and validate header
|
|
20
|
+
version = read.ord
|
|
21
|
+
sig_length = read.ord
|
|
22
|
+
raise "Only version 4 supported, got #{version}" unless version == 4
|
|
23
|
+
raise "Invalid signature length #{sig_length}" unless sig_length == 11
|
|
24
|
+
|
|
25
|
+
signature = read(sig_length)
|
|
26
|
+
case signature
|
|
27
|
+
when "streamtyped" then @int16_format, @int32_format = "v", "V"
|
|
28
|
+
when "typedstream" then @int16_format, @int32_format = "n", "N"
|
|
29
|
+
else raise "Invalid signature: #{signature.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract
|
|
34
|
+
return if @data.nil?
|
|
35
|
+
|
|
36
|
+
# Jump to after NSString and look for '+' string marker
|
|
37
|
+
marker_pos = @data.index(?+, @nsstring_pos + 8)
|
|
38
|
+
return unless marker_pos
|
|
39
|
+
|
|
40
|
+
@pos = marker_pos + 1 # Skip '+' marker
|
|
41
|
+
|
|
42
|
+
length_byte = read.ord # Read string length
|
|
43
|
+
length_byte = length_byte > 127 ? length_byte - 256 : length_byte # Convert to signed byte for tag comparison
|
|
44
|
+
|
|
45
|
+
length = case length_byte
|
|
46
|
+
when TAG_INTEGER_2 then read(2).unpack1(@int16_format) # 2-byte length
|
|
47
|
+
when TAG_INTEGER_4 then read(4).unpack1(@int32_format) # 4-byte length
|
|
48
|
+
else length_byte # Single byte length
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return unless length && length > 0
|
|
52
|
+
|
|
53
|
+
read(length).force_encoding("UTF-8") # Extract and return string
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def read(n = 1)
|
|
59
|
+
bytes = @data[@pos, n]
|
|
60
|
+
@pos += n
|
|
61
|
+
bytes
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
puts AttributedStringExtractor.extract(STDIN.read) if __FILE__ == $0 && STDIN.stat.size > 0
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Binary property list (bplist) parser for decoding Apple's binary plist format
|
|
4
|
+
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module BPList
|
|
8
|
+
APPLE_EPOCH = 978307200 # Apple's epoch offset from Unix epoch
|
|
9
|
+
|
|
10
|
+
# Read big-endian integer from data at position
|
|
11
|
+
def self.read_int(data, pos, size)
|
|
12
|
+
raise "Position #{pos} + #{size} beyond data size" if pos + size > data.bytesize
|
|
13
|
+
case size
|
|
14
|
+
when 1 then data[pos].unpack1("C")
|
|
15
|
+
when 2 then data[pos, 2].unpack1("n")
|
|
16
|
+
when 4 then data[pos, 4].unpack1("N")
|
|
17
|
+
when 8 then data[pos, 8].unpack1("Q>")
|
|
18
|
+
else
|
|
19
|
+
# Fallback for other sizes
|
|
20
|
+
bytes = data[pos, size].unpack("C*")
|
|
21
|
+
bytes.reduce(0) { |a, b|
|
|
22
|
+
raise "nil value in read_int: a=#{a.inspect}, b=#{b.inspect}" if a.nil? || b.nil?
|
|
23
|
+
(a << 8) | b
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get count/length (handles 0xF continuation)
|
|
29
|
+
def self.get_count(data, pos, low)
|
|
30
|
+
return [low, pos + 1] if low != 0x0F
|
|
31
|
+
|
|
32
|
+
raise "Position #{pos + 1} beyond data size" if pos + 1 >= data.bytesize
|
|
33
|
+
int_marker = data[pos + 1].ord
|
|
34
|
+
int_high = int_marker >> 4
|
|
35
|
+
raise "Invalid count marker" unless int_high == 0x1
|
|
36
|
+
|
|
37
|
+
byte_count = 1 << (int_marker & 0x0F)
|
|
38
|
+
count = read_int(data, pos + 2, byte_count)
|
|
39
|
+
[count, pos + 2 + byte_count]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.parse(data)
|
|
43
|
+
data = data.dup.force_encoding("BINARY")
|
|
44
|
+
raise "Invalid header" unless data.start_with?("bplist00")
|
|
45
|
+
|
|
46
|
+
# Parse trailer (last 32 bytes)
|
|
47
|
+
trailer_start = data.bytesize - 32
|
|
48
|
+
offset_int_size = data[trailer_start + 6].ord
|
|
49
|
+
objref_size = data[trailer_start + 7].ord
|
|
50
|
+
num_objects = data.unpack1("Q>", offset: trailer_start + 8)
|
|
51
|
+
root_object_index = data.unpack1("Q>", offset: trailer_start + 16)
|
|
52
|
+
offset_table_pos = data.unpack1("Q>", offset: trailer_start + 24)
|
|
53
|
+
|
|
54
|
+
raise "Invalid trailer" if offset_int_size < 1 || objref_size < 1
|
|
55
|
+
raise "Invalid object count" if num_objects < 1 || root_object_index >= num_objects
|
|
56
|
+
|
|
57
|
+
# Read offset table
|
|
58
|
+
offsets = Array.new(num_objects) do |i|
|
|
59
|
+
pos = offset_table_pos + i * offset_int_size
|
|
60
|
+
read_int(data, pos, offset_int_size)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse objects recursively
|
|
64
|
+
objects = Array.new(num_objects)
|
|
65
|
+
object_cache = {}
|
|
66
|
+
|
|
67
|
+
parse_object = lambda do |index|
|
|
68
|
+
raise "Invalid object ref: #{index}" if index >= num_objects
|
|
69
|
+
return objects[index] if objects[index]
|
|
70
|
+
|
|
71
|
+
# Check cache first
|
|
72
|
+
offset = offsets[index]
|
|
73
|
+
return object_cache[offset] if object_cache.has_key?(offset)
|
|
74
|
+
|
|
75
|
+
# Set placeholder to detect circular refs
|
|
76
|
+
objects[index] = :parsing
|
|
77
|
+
|
|
78
|
+
pos = offsets[index]
|
|
79
|
+
raise "Position #{pos} beyond data size #{data.bytesize}" if pos >= data.bytesize
|
|
80
|
+
marker = data[pos].ord
|
|
81
|
+
high = marker >> 4
|
|
82
|
+
low = marker & 0x0F
|
|
83
|
+
|
|
84
|
+
result = case high
|
|
85
|
+
when 0x0 # Null, Bool, Fill
|
|
86
|
+
case marker
|
|
87
|
+
when 0x00 then nil
|
|
88
|
+
when 0x08 then false
|
|
89
|
+
when 0x09 then true
|
|
90
|
+
else raise "Unknown null type: 0x#{marker.to_s(16)}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
when 0x1 # Integer
|
|
94
|
+
byte_count = 1 << low
|
|
95
|
+
raise "Invalid int size" if byte_count > 16
|
|
96
|
+
raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
|
|
97
|
+
|
|
98
|
+
if byte_count == 16
|
|
99
|
+
# 128-bit integer - read as two 64-bit values (high, low)
|
|
100
|
+
high = data[pos + 1, 8].unpack1("Q>")
|
|
101
|
+
low = data[pos + 9, 8].unpack1("Q>")
|
|
102
|
+
# Convert to signed if high MSB is set
|
|
103
|
+
if high >= (1 << 63)
|
|
104
|
+
high = high - (1 << 64)
|
|
105
|
+
end
|
|
106
|
+
# Ruby handles big integers automatically
|
|
107
|
+
(high << 64) | low
|
|
108
|
+
else
|
|
109
|
+
value = read_int(data, pos + 1, byte_count)
|
|
110
|
+
# Per Apple spec: only 8+ byte integers are signed, 1/2/4 byte are unsigned
|
|
111
|
+
if byte_count >= 8 && value >= (1 << (byte_count * 8 - 1))
|
|
112
|
+
value - (1 << (byte_count * 8))
|
|
113
|
+
else
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
when 0x2 # Real
|
|
119
|
+
byte_count = 1 << low
|
|
120
|
+
raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
|
|
121
|
+
case byte_count
|
|
122
|
+
when 4 then data[pos + 1, 4].unpack1("g")
|
|
123
|
+
when 8 then data[pos + 1, 8].unpack1("G")
|
|
124
|
+
else raise "Invalid real size: #{byte_count}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
when 0x3 # Date
|
|
128
|
+
raise "Invalid date marker" unless marker == 0x33
|
|
129
|
+
raise "Position #{pos + 1} + 8 beyond data size" if pos + 1 + 8 > data.bytesize
|
|
130
|
+
seconds = data[pos + 1, 8].unpack1("G")
|
|
131
|
+
Time.at(APPLE_EPOCH + seconds)
|
|
132
|
+
|
|
133
|
+
when 0x4 # Data
|
|
134
|
+
count, start = get_count(data, pos, low)
|
|
135
|
+
raise "Position #{start} + #{count} beyond data size" if start + count > data.bytesize
|
|
136
|
+
data[start, count]
|
|
137
|
+
|
|
138
|
+
when 0x5 # ASCII string
|
|
139
|
+
count, start = get_count(data, pos, low)
|
|
140
|
+
raise "Position #{start} + #{count} beyond data size" if start + count > data.bytesize
|
|
141
|
+
ascii_data = data[start, count]
|
|
142
|
+
# Validate ASCII - check for non-ASCII bytes
|
|
143
|
+
ascii_data.force_encoding("US-ASCII")
|
|
144
|
+
if ascii_data.valid_encoding?
|
|
145
|
+
ascii_data.encode!("UTF-8")
|
|
146
|
+
else
|
|
147
|
+
# Invalid ASCII, keep as binary for later Base64 encoding
|
|
148
|
+
ascii_data.force_encoding("BINARY")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
when 0x6 # UTF-16 string
|
|
152
|
+
count, start = get_count(data, pos, low)
|
|
153
|
+
raise "Position #{start} + #{count * 2} beyond data size" if start + count * 2 > data.bytesize
|
|
154
|
+
utf16_data = data[start, count * 2]
|
|
155
|
+
# Convert UTF-16BE to UTF-8
|
|
156
|
+
begin
|
|
157
|
+
utf16_data.force_encoding("UTF-16BE").encode!("UTF-8")
|
|
158
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
159
|
+
# Invalid UTF-16, keep as binary for later Base64 encoding
|
|
160
|
+
utf16_data.force_encoding("BINARY")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
when 0x8 # UID
|
|
164
|
+
byte_count = low + 1
|
|
165
|
+
raise "Position #{pos + 1} + #{byte_count} beyond data size" if pos + 1 + byte_count > data.bytesize
|
|
166
|
+
{ "CF$UID" => read_int(data, pos + 1, byte_count)}
|
|
167
|
+
|
|
168
|
+
when 0xA # Array
|
|
169
|
+
count, start = get_count(data, pos, low)
|
|
170
|
+
raise "Position #{start} + #{count * objref_size} beyond data size" if start + count * objref_size > data.bytesize
|
|
171
|
+
Array.new(count) { |i| parse_object.call(read_int(data, start + i * objref_size, objref_size)) }
|
|
172
|
+
|
|
173
|
+
when 0xC # Set
|
|
174
|
+
count, start = get_count(data, pos, low)
|
|
175
|
+
raise "Position #{start} + #{count * objref_size} beyond data size" if start + count * objref_size > data.bytesize
|
|
176
|
+
Set.new(Array.new(count) { |i| parse_object.call(read_int(data, start + i * objref_size, objref_size)) })
|
|
177
|
+
|
|
178
|
+
when 0xD # Dict
|
|
179
|
+
count, start = get_count(data, pos, low)
|
|
180
|
+
raise "Position #{start} + #{count * objref_size * 2} beyond data size" if start + count * objref_size * 2 > data.bytesize
|
|
181
|
+
Array.new(count) { |i|
|
|
182
|
+
[ parse_object.call(read_int(data, start + i * objref_size, objref_size)),
|
|
183
|
+
parse_object.call(read_int(data, start + (count + i) * objref_size, objref_size))]
|
|
184
|
+
}.to_h
|
|
185
|
+
|
|
186
|
+
else
|
|
187
|
+
raise "Unknown marker: 0x#{marker.to_s(16)}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
objects[index] = result
|
|
191
|
+
# Cache the parsed object by its offset for reuse
|
|
192
|
+
object_cache[offset] = result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
parse_object.call(root_object_index)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__END__
|
|
202
|
+
|
|
203
|
+
Binary plist format specification (based on Apple CFBinaryPList.c):
|
|
204
|
+
|
|
205
|
+
HEADER
|
|
206
|
+
magic number ("bplist")
|
|
207
|
+
file format version (currently "0?")
|
|
208
|
+
|
|
209
|
+
OBJECT TABLE
|
|
210
|
+
variable-sized objects
|
|
211
|
+
|
|
212
|
+
Object Formats (marker byte followed by additional info in some cases)
|
|
213
|
+
null 0000 0000 // null object [v"1?"+ only]
|
|
214
|
+
bool 0000 1000 // false
|
|
215
|
+
bool 0000 1001 // true
|
|
216
|
+
url 0000 1100 string // URL with no base URL, recursive encoding of URL string [v"1?"+ only]
|
|
217
|
+
url 0000 1101 base string // URL with base URL, recursive encoding of base URL, then recursive encoding of URL string [v"1?"+ only]
|
|
218
|
+
uuid 0000 1110 // 16-byte UUID [v"1?"+ only]
|
|
219
|
+
fill 0000 1111 // fill byte
|
|
220
|
+
int 0001 0nnn ... // # of bytes is 2^nnn, big-endian bytes
|
|
221
|
+
real 0010 0nnn ... // # of bytes is 2^nnn, big-endian bytes
|
|
222
|
+
date 0011 0011 ... // 8 byte float follows, big-endian bytes
|
|
223
|
+
data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
|
|
224
|
+
string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
|
|
225
|
+
string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
|
|
226
|
+
string 0111 nnnn [int] ... // UTF8 string, nnnn is # of chars, else 1111 then int count, then bytes [v"1?"+ only]
|
|
227
|
+
uid 1000 nnnn ... // nnnn+1 is # of bytes
|
|
228
|
+
1001 xxxx // unused
|
|
229
|
+
array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
|
230
|
+
ordset 1011 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows [v"1?"+ only]
|
|
231
|
+
set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows [v"1?"+ only]
|
|
232
|
+
dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
|
|
233
|
+
1110 xxxx // unused
|
|
234
|
+
1111 xxxx // unused
|
|
235
|
+
|
|
236
|
+
OFFSET TABLE
|
|
237
|
+
list of ints, byte size of which is given in trailer
|
|
238
|
+
-- these are the byte offsets into the file
|
|
239
|
+
-- number of these is in the trailer
|
|
240
|
+
|
|
241
|
+
TRAILER
|
|
242
|
+
byte size of offset ints in offset table
|
|
243
|
+
byte size of object refs in arrays and dicts
|
|
244
|
+
number of offsets in offset table (also is number of objects)
|
|
245
|
+
element # in offset table which is top level object
|
|
246
|
+
offset table offset
|
|
247
|
+
|
|
248
|
+
**Integer Signedness (per Apple spec):**
|
|
249
|
+
- 1, 2, 4-byte integers: Always unsigned
|
|
250
|
+
- 8+ byte integers: Signed when MSB is set (two's complement)
|
|
251
|
+
- 16-byte integers: 128-bit signed (high 64-bit + low 64-bit)
|
|
252
|
+
|
|
253
|
+
**Encoding:**
|
|
254
|
+
- Big-endian byte order throughout
|
|
255
|
+
- Length in low nibble (0-14 direct, 0xF = follow-on integer)
|
|
256
|
+
- Object references are indices into offset table
|
|
257
|
+
- Strings: ASCII (0x5X), UTF-16BE (0x6X), UTF-8 (0x7X, v1+ only)
|