-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhyperparse.py
More file actions
2115 lines (1831 loc) · 87.3 KB
/
Copy pathhyperparse.py
File metadata and controls
2115 lines (1831 loc) · 87.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
hyperparse.py - a reverse-engineered reader for Tableau .hyper files.
Reads .hyper files without Tableau's Hyper API: the two root records, the
storage directory, every object it addresses, the system catalog, and the
actual row values inside the Data Blocks.
python3 hyperparse.py FILE... superblocks + directory + catalog
python3 hyperparse.py --blocks FILE per-column block geometry + SMA
python3 hyperparse.py --rows 10 FILE reconstruct actual row values
python3 hyperparse.py --json FILE machine-readable output
python3 hyperparse.py --verify FILE cross-check vs the real API
python3 hyperparse.py --dump 0x2000 FILE hexdump at an offset
No third-party dependencies: the LZ4 block decoder and CRC-32C are inline.
If `tableauhyperapi` happens to be installed, --verify uses it as an oracle.
CONFIDENCE
The container is confirmed against hyper::RootRecord, hyper::FileStorageResource
and hyper::ObjectStore in the macOS arm64 `hyperd` (which, unlike the Linux
build, ships full C++ symbols), and CRC-verified byte-for-byte on every sample.
The Data Block header and region map come from the engine's own assertion
strings in getPackInfoAndValidate. The encodings were measured against the
Hyper API as an oracle: 276 columns / 36 relations, value multisets and whole
row tuples. The one thing still guessed is the DataBlockCompression enum,
whose behaviour is tabulated (SCHEMES) but whose definition is not decoded -
an unknown code makes the decoder refuse rather than invent values.
Structures are named after the engine's own classes so that the code and a
disassembler view line up. See README.md for the field tables and the evidence.
"""
from __future__ import annotations
import argparse
import bisect
import json
import math
import struct
import sys
import uuid
from dataclasses import dataclass, field, asdict
from typing import Any, NamedTuple
class IntervalValue(NamedTuple):
"""Hyper's 16-byte interval_t: {u8 microseconds, i4 days, u4 months}.
A NamedTuple so it stays hashable - decoded values end up in Counters
during validation, and a dict would not.
"""
months: int
days: int
microseconds: int
def __str__(self) -> str:
return f"{self.months}mo {self.days}d {self.microseconds}us"
PAGE_SIZE = 0x1000
EXTENT_SIZE = 0x10000 # files are always a whole number of these
MAX_U64 = (1 << 64) - 1
SB_MAGIC = b"Hyper\x08" # RootRecord::initHeader writes exactly these 6 bytes
PKG_MAGIC = b"HyperDB\x00"
CATALOG_NEEDLE = b'{"compressionMethod'
SB_CHECKSUM_OFF = 0x0FFC
DIR_MAGIC = 0xDA1ADA1A # u64 sentinel closing the storage directory
ENTRY_SIZE = 0x30 # sizeof(ObjectStoreIdMapEntry)
# --------------------------------------------------------------------------
# CRC-32C as Hyper computes it
#
# hyper::crc32cIntrinsic(ptr, len, crc) is a *raw* accumulator over the ARMv8
# crc32cx/crc32cb instructions: no pre-inversion of the seed, no post-
# inversion of the result. Every off-the-shelf CRC-32C ("Castagnoli") helper
# uses init=0xFFFFFFFF/xorout=0xFFFFFFFF, which is why this looked unbreakable
# until the routine itself was read.
# --------------------------------------------------------------------------
_CRC32C_TABLE = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = (_c >> 1) ^ (0x82F63B78 if _c & 1 else 0)
_CRC32C_TABLE.append(_c)
def crc32c(data: bytes, seed: int = 0) -> int:
"""Reflected CRC-32C, init=seed, no final XOR."""
crc = seed
for b in data:
crc = _CRC32C_TABLE[(crc ^ b) & 0xFF] ^ (crc >> 8)
return crc & 0xFFFFFFFF
def crc_seed_for_version(format_version: int) -> int:
"""hyper::RootRecord::getCRCSeedForVersion.
Old databases seed object/directory CRCs with 0; from the format version
that gated the `nonZeroChecksumSeed` capability onwards the seed is 0x1234.
The threshold is not read out of the binary here, so both are tried.
"""
return 0x1234 if format_version >= 1 else 0
# hyper::ObjectStoreId::Category
CATEGORY = {
1: "Database_Header",
2: "Schema",
3: "Relation_Header",
4: "Relation_DataBlock",
5: "Relation_Metadata",
6: "Relation_Sample",
7: "Index",
8: "Database_EncryptionKey",
}
# hyper::DatabaseInfo::Compression, as stored in ObjectStoreIdMapEntry+0x29
COMPRESSION = {0: "none", 1: "lz4", 2: "?2"}
# --------------------------------------------------------------------------
# LZ4 block format (raw, no frame header - Hyper does not use frames)
# --------------------------------------------------------------------------
def lz4_block_decode(src: bytes, start: int = 0, limit: int = 1 << 24) -> tuple[bytes, int]:
"""Decode as far as the stream stays self-consistent; return (out, end).
Deliberately tolerant: a normal decoder needs the output size up front and
raises on the first inconsistency. For exploration we want to decode until
the stream stops making sense and learn where that happened.
"""
out = bytearray()
i, n = start, len(src)
while i < n and len(out) < limit:
token = src[i]; i += 1
lit = token >> 4
if lit == 15:
while i < n:
b = src[i]; i += 1
lit += b
if b != 255:
break
else:
break
if i + lit > n:
break
out += src[i:i + lit]
i += lit
if i + 2 > n:
break # legal: block ends on literals
offset = src[i] | (src[i + 1] << 8)
i += 2
if offset == 0 or offset > len(out):
i -= 2
break # bad back-reference: stop clean
match = token & 0x0F
if match == 15:
while i < n:
b = src[i]; i += 1
match += b
if b != 255:
break
else:
break
match += 4
p = len(out) - offset
for _ in range(match): # byte-wise: overlap is legal
out.append(out[p]); p += 1
return bytes(out), i
# --------------------------------------------------------------------------
# Structures
# --------------------------------------------------------------------------
@dataclass
class Superblock:
"""hyper::RootRecord - one 4 KiB page, two of them, written alternately."""
offset: int
struct_version: int
format_version: int
encrypted: int # +0x0C, a bool in StorageMetadata's ctor
creator_version: str # +0x18, {u4 major, u4 minor, u4 build}
min_version: str # +0x24, same shape
creator_build: int # the build component alone, for convenience
txn_id: int
file_size: int
dir_offset: int # +0x40 \ hyper::Position{offset, length} of the
dir_length: int # +0x48 / storage directory; relocates on commit
dir_capacity: int # +0x50, allocated bytes for the directory
commit_id: int
checksum: int
checksum_ok: bool
unknown_tail: str # hex of any non-zero bytes we do not model
@property
def page_index(self) -> int:
return self.offset // PAGE_SIZE
@dataclass
class ObjectEntry:
"""hyper::ObjectStoreIdMapEntry - 48 bytes, memcpy'd straight out of the file."""
slot: int
category: int # ObjectStoreId+0x06
index: int # ObjectStoreId+0x00, 24 bits: column, or 0 for
# the per-block partition header
relation: int # ObjectStoreId+0x0C: index into the catalog's
# relations[] array. Verified on 68/68 files,
# including ones whose relation oids are
# non-contiguous, so it is an array position
# and not an oid.
block: int # ObjectStoreId+0x08: which block of the column
field_b: int # ObjectStoreId+0x03, 24 bits. Role unknown. It
# equals `relation` in Tableau-written files
# and is 0 in hyperapi-written ones, so it
# enumerates the relations in only 64/68 -
# do not key on it.
size: int # payload bytes, excluding the trailing CRC
offset: int # Position.offset
alloc: int # Position.length, >= size + 4
compression: int
encryption: int
crc_ok: bool | None = None
@property
def category_name(self) -> str:
return CATEGORY.get(self.category, f"Unknown({self.category})")
def __str__(self) -> str:
"""Field order as hyper::ObjectStoreId::to_string prints it, which is
not the order the fields are laid out in."""
return (f"{self.category_name}.{self.field_b}.{self.index}"
f".{self.relation}.{self.block}")
@dataclass
class Directory:
offset: int
length: int
log2_capacity: int
entries: list[ObjectEntry]
free_list: list[tuple[int, int]]
magic_ok: bool
crc_ok: bool
crc_seed: int
@dataclass
class PackageHeader:
offset: int
kind: int
struct_version: int
format_version: int
database_uuid: str
checksum: int
@dataclass
class Catalog:
offset: int
length: int
digest: int
framing: str # "packaged" | "bare"
package: PackageHeader | None
data: dict
@dataclass
class ColumnBlock:
"""One Relation_DataBlock object, resolved against the catalog.
index 0 of a relation is the partition header (just a tuple count); index N
is column N-1. Nothing here is found by scanning - the directory says
exactly where each block is and the catalog says what type it holds.
"""
relation: int
index: int
block: int # ObjectStoreId+0x08: which block of the column
column: str | None # None for the partition header
sql_type: str | None
offset: int
stored_len: int
decoded_len: int
compression: str
tuple_count: int
hdr: "BlockIndex | None" = None # the decoded per-column header
sma_min: Any = None
sma_max: Any = None
note: str = ""
@property
def code_bits(self) -> float:
"""Bits per row in the data region. Dictionary codes are bit-packed at
1/2/4 bits and byte-packed at 8/16/32, so this lands near a power of two.
Derived, not read from the block."""
if not self.hdr or not self.tuple_count:
return 0.0
return self.hdr.data_size * 8 / self.tuple_count
@dataclass
class HyperFile:
path: str
size: int
superblocks: list[Superblock] = field(default_factory=list)
directory: Directory | None = None
catalogs: list[Catalog] = field(default_factory=list)
blocks: list[ColumnBlock] = field(default_factory=list)
_live_catalog: Catalog | None = None # from the directory, not the scan
warnings: list[str] = field(default_factory=list)
@property
def live_catalog(self) -> Catalog | None:
"""The catalog the live directory points at, read from its Schema object.
Any other catalog in the file is an earlier transaction's state. Falls
back to whatever the forensic scan found only when there is no usable
directory - e.g. a truncated or corrupt file.
"""
if self._live_catalog is not None:
return self._live_catalog
return self.catalogs[0] if self.catalogs else None
@property
def live(self) -> Superblock | None:
"""hyper::RootRecord::selectValidRootRecord: highest txn_id among the
superblocks that pass magic, struct version and CRC."""
good = [s for s in self.superblocks if s.checksum_ok]
return max(good or self.superblocks, key=lambda s: s.txn_id) if self.superblocks else None
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
def _u(fmt: str, d: bytes, off: int) -> int:
return struct.unpack_from(fmt, d, off)[0]
def parse_superblock(d: bytes, base: int, warn) -> Superblock | None:
"""Mirrors hyper::RootRecord::isValid + the StorageMetadata(RootRecord&) ctor."""
if len(d) < base + PAGE_SIZE:
warn(f"file too short for superblock at {base:#x}")
return None
if d[base:base + 6] != SB_MAGIC:
warn(f"bad superblock magic at {base:#x}: {d[base:base+8]!r}")
return None
struct_version = _u("<H", d, base + 0x08)
format_version = _u("<H", d, base + 0x0A)
if struct_version != 1:
warn(f"superblock {base:#x}: struct_version {struct_version} != 1; "
f"the engine rejects anything else")
# isValid() accepts (format_version + 1) & 0xFFFF <= 5, i.e. 0..4 or 0xFFFF.
if (format_version + 1) & 0xFFFF > 5:
warn(f"superblock {base:#x}: format version {format_version} is outside "
f"the range the engine accepts (0-4, or 0xFFFF for unset)")
# Flag any non-zero byte in regions we believe are reserved, so that a file
# from a newer build tells us it has fields we do not know about. This
# tripwire is what exposed the version triples at 0x18/0x24 - an earlier
# revision read 0x20 as one u8 and so straddled `build` and the next
# `major`, which is why Tableau-written files reported builds in the
# billions.
unknown = bytearray()
for lo, hi in ((0x0D, 0x18), (0x58, 0x60), (0x68, 0x80)):
chunk = d[base + lo: base + hi]
if any(chunk):
unknown += chunk
if any(d[base + 0x80: base + SB_CHECKSUM_OFF]):
warn(f"superblock {base:#x}: unexpected data in 0x80..0xFFC")
cmaj, cmin, cbuild, mmaj, mmin, mbuild = struct.unpack_from("<6I", d, base + 0x18)
stored = _u("<I", d, base + SB_CHECKSUM_OFF)
return Superblock(
offset=base,
struct_version=struct_version,
format_version=format_version,
encrypted=d[base + 0x0C],
creator_version=f"{cmaj}.{cmin}.{cbuild}",
min_version=f"{mmaj}.{mmin}.{mbuild}",
creator_build=cbuild,
txn_id=_u("<Q", d, base + 0x30),
file_size=_u("<Q", d, base + 0x38),
dir_offset=_u("<Q", d, base + 0x40),
dir_length=_u("<Q", d, base + 0x48),
dir_capacity=_u("<Q", d, base + 0x50),
commit_id=_u("<Q", d, base + 0x60),
checksum=stored,
checksum_ok=crc32c(d[base:base + SB_CHECKSUM_OFF]) == stored,
unknown_tail=unknown.hex(),
)
def parse_directory(d: bytes, sb: Superblock, warn) -> Directory | None:
"""hyper::FileStorageResource::readDirectory.
u64 log2 of the hash-table capacity
ObjectStoreIdMapEntry[1<<n] 48 bytes each, raw
u64 free-list length
Position[len] {offset, length} of every free chunk
u64 0xDA1ADA1A
(zero padding out to dir_length)
u32 CRC-32C of the preceding dir_length bytes
The CRC sits *after* the payload, at dir_offset + dir_length, which is why
it reads as zero if you look for it at the more natural length - 4.
"""
off, ln = sb.dir_offset, sb.dir_length
if not (0 < off < len(d)) or ln < 16 or off + ln + 4 > len(d):
warn(f"directory Position ({off:#x}, {ln}) does not fit the file")
return None
blob = d[off:off + ln]
stored = _u("<I", d, off + ln)
seed = next((s for s in (crc_seed_for_version(sb.format_version), 0, 0x1234)
if crc32c(blob, s) == stored), None)
if seed is None:
warn(f"directory CRC mismatch: stored {stored:#010x}")
log2cap = _u("<Q", blob, 0)
if log2cap > 0x3A:
warn(f"directory hash shift {log2cap} exceeds the engine's own limit")
return None
cap = 1 << log2cap
end = 8 + cap * ENTRY_SIZE
if end + 8 > ln:
warn(f"directory too short for {cap} entries")
return None
entries = []
for i in range(cap):
e = blob[8 + ENTRY_SIZE * i:8 + ENTRY_SIZE * (i + 1)]
if e[0x28] != 1: # 0 empty, 1 used, 2 tombstone
continue
size, pos_off, pos_len = struct.unpack_from("<QQQ", e, 0x10)
entry = ObjectEntry(
slot=i,
index=int.from_bytes(e[0x00:0x03], "little"),
field_b=int.from_bytes(e[0x03:0x06], "little"),
category=_u("<H", e, 0x06),
block=_u("<I", e, 0x08),
relation=_u("<I", e, 0x0C),
size=size, offset=pos_off, alloc=pos_len,
compression=e[0x29], encryption=e[0x2A],
)
if pos_off + size + 4 <= len(d):
entry.crc_ok = any(crc32c(d[pos_off:pos_off + size], s) ==
_u("<I", d, pos_off + size)
for s in ({seed} if seed is not None else {0, 0x1234}))
entries.append(entry)
n_free = _u("<Q", blob, end)
free = [struct.unpack_from("<QQ", blob, end + 8 + 16 * k) for k in range(n_free)]
magic_at = end + 8 + 16 * n_free
magic_ok = magic_at + 8 <= ln and _u("<Q", blob, magic_at) == DIR_MAGIC
if not magic_ok:
warn("directory sentinel 0xDA1ADA1A not found where expected")
return Directory(offset=off, length=ln, log2_capacity=log2cap, entries=entries,
free_list=free, magic_ok=magic_ok, crc_ok=seed is not None,
crc_seed=seed if seed is not None else -1)
def read_object(d: bytes, e: ObjectEntry) -> bytes:
"""Payload of one object, LZ4-decompressed when the entry says so.
A compressed object is a u32 uncompressed length followed by a raw LZ4
block - no frame header, which is what made the framing look exotic.
"""
raw = d[e.offset:e.offset + e.size]
if e.compression == 0:
return raw
want = _u("<I", raw, 0)
out, _ = lz4_block_decode(raw, 4, limit=max(want, 1) * 2)
return out[:want] if want else out
def parse_package(d: bytes, off: int) -> PackageHeader | None:
if off < 0 or off + 0x40 > len(d) or d[off:off + 8] != PKG_MAGIC:
return None
return PackageHeader(
offset=off,
kind=_u("<I", d, off + 0x08),
struct_version=_u("<H", d, off + 0x0C),
format_version=_u("<H", d, off + 0x0E),
database_uuid=str(uuid.UUID(bytes_le=d[off + 0x10:off + 0x20])),
checksum=_u("<I", d, off + 0x30),
)
def brace_match(d: bytes, start: int) -> int:
"""End offset of the JSON object at `start`. Length is stored nowhere, so
the blob is delimited purely structurally. String and escape aware."""
depth = 0
in_str = False
esc = False
i = start
while i < len(d):
c = d[i]
if in_str:
if esc:
esc = False
elif c == 0x5C:
esc = True
elif c == 0x22:
in_str = False
elif c == 0x22:
in_str = True
elif c == 0x7B:
depth += 1
elif c == 0x7D:
depth -= 1
if depth == 0:
return i + 1
i += 1
raise ValueError(f"unterminated catalog JSON at {start:#x}")
def read_catalog(d: bytes, e: ObjectEntry, warn) -> Catalog | None:
"""The catalog named by a directory entry - the authoritative path.
The entry carries offset, length, compression and CRC, so nothing has to be
searched for and nothing has to be brace-matched. Prefer this over
find_catalogs() for anything the live directory references.
"""
try:
payload = json.loads(read_object(d, e))
except (ValueError, json.JSONDecodeError) as ex:
warn(f"Schema object at {e.offset:#x} is not valid JSON: {ex}")
return None
return Catalog(offset=e.offset, length=e.size,
digest=_u("<I", d, e.offset + e.size)
if e.offset + e.size + 4 <= len(d) else 0,
framing="object", package=parse_package(d, e.offset - 0x40),
data=payload)
def find_catalogs(d: bytes, warn) -> list[Catalog]:
"""Scan the whole file for catalog JSON. **Forensic only.**
This finds catalogs the live directory no longer references - earlier
transactions' states, which is genuinely useful when reconstructing history.
It is the wrong way to find the *current* catalog: read_catalog() does that
from the directory.
The scan rests on two things the format does not guarantee: that
`compressionMethod` is serialised first (JSON objects have no key order),
and that the object is stored uncompressed (`lz4` is legal for any object,
in which case the needle cannot match at all). Both hold across the corpus;
neither is a rule.
"""
out: list[Catalog] = []
pos = 0
while True:
pos = d.find(CATALOG_NEEDLE, pos)
if pos < 0:
return out
try:
end = brace_match(d, pos)
payload = json.loads(d[pos:end])
except (ValueError, json.JSONDecodeError) as e:
warn(f"catalog at {pos:#x} failed to parse: {e}")
pos += len(CATALOG_NEEDLE)
continue
pkg = parse_package(d, pos - 0x40)
out.append(Catalog(
offset=pos,
length=end - pos,
digest=_u("<I", d, end) if end + 4 <= len(d) else 0,
framing="packaged" if pkg else "bare",
package=pkg,
data=payload,
))
pos = end
SMA_OFF = 0x30 # the SMA pair starts here, right after the five header words
SMA_SLOT = 16 # variable-width types only; fixed-width types are packed
SMA_INLINE_CAP = 12 # a string SMA slot is u4 length + at most 12 bytes of text
# Byte width of a fixed-width SMA value, keyed by the catalog's base type name.
# Fixed-width min and max are stored ADJACENTLY at SMA_OFF and SMA_OFF+width -
# the 16-byte slot rule applies only to the variable-width types below.
SMA_FIXED_WIDTH = {
"Bool": 1,
"SmallInt": 2,
"Integer": 4, "Date": 4, "Oid": 4, "Float": 4,
"BigInt": 8, "Double": 8, "Timestamp": 8, "TimestampTZ": 8, "Time": 8,
"Numeric": 8,
"Interval": 16, "BigNumeric": 16,
}
# Names are the catalog's own spellings, which are not the SqlType method names:
# bytes() -> "Bytea", json() -> "JSON", numeric(38,s) -> "BigNumeric".
SMA_VARIABLE = {"Varchar", "Text", "Char", "Bytea", "JSON",
"Geography", "TabGeography"}
# Values that are raw octets, not text - decoding them as UTF-8 corrupts them.
SMA_BINARY = {"Bytea", "Geography", "TabGeography"}
# Two's-complement types. Date/Time/Timestamp/Oid are non-negative counters.
SMA_SIGNED = {"SmallInt", "Integer", "BigInt", "Numeric", "BigNumeric"}
@dataclass(frozen=True)
class ColType:
"""A catalog column type: a name plus any numeric modifiers.
The catalog stores these as a flat array, e.g. ["Numeric", 18, 4,
"nullable"] or ["Char", 8]. The modifiers matter - a Numeric cannot be
rendered without its scale.
"""
name: str | None
mods: tuple[int, ...] = ()
@classmethod
def parse(cls, arr: list | None) -> "ColType":
arr = arr or []
mods = tuple(x for x in arr[1:] if isinstance(x, int))
return cls(arr[0] if arr else None, mods)
@property
def scale(self) -> int:
"""Decimal scale for Numeric/BigNumeric; 0 for everything else."""
return self.mods[1] if len(self.mods) > 1 else 0
@property
def is_variable(self) -> bool:
return self.name in SMA_VARIABLE
@property
def width(self) -> int:
return SMA_FIXED_WIDTH.get(self.name or "", 0)
def __str__(self) -> str:
return self.name + ("/" + "/".join(map(str, self.mods)) if self.mods else "") \
if self.name else "?"
UNIX_EPOCH_JDN = 2440588 # JDN of 1970-01-01
US_PER_DAY = 86_400_000_000
@dataclass
class BlockIndex:
"""The per-column header, named from hyper's own assertion strings in
`getPackInfoAndValidate` ("index.dictOffset<=index.dataOffset", …).
+0x00 u4 tuple_count (the engine reads this as a u4)
+0x04 -- padding never read; 0 in all 870 headers seen
+0x08 u8 dict_offset \
+0x10 u8 data_offset > all relative to SMA_OFF, all 16-byte aligned
+0x18 u4 scheme | DataBlockCompression, asserted < 54
+0x20 u8 string_offset /
+0x30 SMA, then PSMA, then domain size
The four regions are contiguous: sma = [0x30, 0x30+dict_offset),
dict = [.., 0x30+data_offset), data = [.., 0x30+string_offset),
string_data = [.., object end).
"""
tuple_count: int
dict_offset: int
data_offset: int
scheme: int
string_offset: int
@property
def sma_size(self) -> int:
return self.dict_offset
@property
def dict_size(self) -> int:
return self.data_offset - self.dict_offset
@property
def data_size(self) -> int:
return self.string_offset - self.data_offset
def string_data(self, blob: bytes) -> bytes:
return blob[SMA_OFF + self.string_offset:]
def violations(self, size: int) -> list[str]:
"""The engine's own invariants. Any hit means we have mis-modelled it."""
bad = []
if self.scheme >= 54:
bad.append(f"scheme {self.scheme} >= {N_SCHEMES}")
if not (self.dict_offset <= self.data_offset <= self.string_offset):
bad.append("offsets not monotonic")
if self.string_offset > max(0, size - SMA_OFF):
bad.append("stringDataOffset past end of object")
for nm, v in (("dict", self.dict_offset), ("data", self.data_offset),
("stringData", self.string_offset)):
if v % 16:
bad.append(f"{nm}Offset {v} not 16-byte aligned")
return bad
def parse_block_index(b: bytes) -> BlockIndex | None:
if len(b) < SMA_OFF:
return None
dict_off, data_off = struct.unpack_from("<QQ", b, 0x08)
return BlockIndex(tuple_count=_u("<I", b, 0x00), dict_offset=dict_off,
data_offset=data_off, scheme=_u("<I", b, 0x18),
string_offset=_u("<Q", b, 0x20))
def jdn_to_ymd(jdn: int) -> tuple[int, int, int]:
"""Julian day number -> proleptic Gregorian (year, month, day).
Fliegel-Van Flandern. Used instead of datetime.date because Hyper's DATE
reaches year 294276 while Python's datetime stops at 9999 - a date past
that raised ValueError and fell through to a raw-bytes dict.
"""
a = jdn + 32044
b = (4 * a + 3) // 146097
c = a - (146097 * b) // 4
d = (4 * c + 3) // 1461
e = c - (1461 * d) // 4
m = (5 * e + 2) // 153
return (100 * b + d - 4800 + m // 10, m + 3 - 12 * (m // 10),
e - (153 * m + 2) // 5 + 1)
def _fmt_date(jdn: int) -> str:
y, m, d = jdn_to_ymd(jdn)
return f"{y:04d}-{m:02d}-{d:02d}"
def _fmt_timestamp(us_since_jdn0: int, tz: bool) -> str:
days, rem = divmod(us_since_jdn0, US_PER_DAY)
hh, rem = divmod(rem, 3_600_000_000)
mm, rem = divmod(rem, 60_000_000)
ss, us = divmod(rem, 1_000_000)
out = f"{_fmt_date(days)} {hh:02d}:{mm:02d}:{ss:02d}"
if us:
out += f".{us:06d}"
return out + ("+00:00" if tz else "")
def _render_fixed(raw: bytes, ct: ColType) -> Any:
"""Turn a fixed-width value into a Python object.
Every encoding below was read off a probe block whose values were known in
advance - see probe3.py. Widths: Numeric is a scaled i8, BigNumeric a
scaled i16 (128-bit), Interval a {u8 microseconds, u4 days, u4 months}
triple, Time microseconds since midnight, TimestampTZ the same epoch as
Timestamp (stored in UTC).
"""
import datetime
import decimal
name = ct.name
n = int.from_bytes(raw, "little", signed=name in SMA_SIGNED)
try:
if name == "Bool":
return bool(n)
if name == "Date":
return _fmt_date(n)
if name in ("Timestamp", "TimestampTZ"):
# TimestampTZ is stored in UTC; say so, as the engine does.
return _fmt_timestamp(n, name == "TimestampTZ")
if name == "Time":
return str((datetime.datetime.min +
datetime.timedelta(microseconds=n)).time())
if name == "Double":
return struct.unpack("<d", raw)[0]
if name == "Float":
return struct.unpack("<f", raw)[0]
if name in ("Numeric", "BigNumeric"):
# Build from a string so the decimal context cannot round: a
# BigNumeric(38, 6) needs more digits than the default precision.
return decimal.Decimal(f"{n}e-{ct.scale}")
if name == "Interval":
us, days, months = struct.unpack("<QiI", raw[:16])
return IntervalValue(months, days, us)
except (ValueError, OverflowError, struct.error, decimal.InvalidOperation):
return {"raw": raw.hex(), "int": n}
return n
def decode_sma(b: bytes, ct: ColType,
string_data: bytes = b"") -> tuple[Any, Any, str]:
"""(min, max, note) for one column block.
Two shapes, and which one applies is decided by the declared type rather
than guessed from the bytes. Guessing is what made Quantity's min=1/max=14
read back as a bogus 8-byte integer: a fixed-width value has no length
prefix, so treating the leading u32 as one is always wrong.
"""
if ct.is_variable:
# Both slots are a FIXED 16 bytes - min at 0x30, max at 0x40, never
# spilling. A slot is a small-string-optimised value:
# u4 length; if length <= 12 the text follows inline,
# otherwise u4 (zero) then u8 offset into string_data.
# The string heap is laid out min first, max second, then the rest of
# the dictionary - which is why the max's offset always equals the
# min's length. Decoding the non-inlined case as inline text yields
# convincing garbage; that was a real bug in an earlier revision.
out, unresolved = [], False
for k in range(2):
off = SMA_OFF + k * SMA_SLOT
if off + SMA_SLOT > len(b):
out.append(None)
continue
n = _u("<I", b, off)
if n <= SMA_INLINE_CAP:
out.append(_bytes_as(b[off + 4:off + 4 + n], ct))
continue
at = _u("<Q", b, off + 8)
if at + n <= len(string_data):
out.append(_bytes_as(string_data[at:at + n], ct))
else:
out.append(f"<{n} bytes at string_data+{at}, out of range>")
unresolved = True
return out[0], out[1], "SMA string offset out of range" if unresolved else ""
width = ct.width
if not width:
return b[SMA_OFF:SMA_OFF + 8].hex(), b[SMA_OFF + 8:SMA_OFF + 16].hex(), \
f"unmodelled type {ct}; SMA shown raw"
if SMA_OFF + 2 * width > len(b):
return None, None, "block too short for an SMA pair"
lo = _render_fixed(b[SMA_OFF:SMA_OFF + width], ct)
hi = _render_fixed(b[SMA_OFF + width:SMA_OFF + 2 * width], ct)
return lo, hi, ""
def _align16(n: int) -> int:
return -(-n // 16) * 16
def unpack_codes(buf: bytes, n: int, bits: int) -> list[int]:
"""Unpack n codes of `bits` each.
Sub-byte codes are packed **MSB-first**: the first code occupies the high
bits of the first byte. Confirmed by probe - three values with counts 3/5/8
give `01 55 aa aa` at 2 bits, which is 0,0,0,1 | 1,1,1,1 | 2,2,2,2 | 2,2,2,2
read from the top. LSB-first would put the first transition at 0x40.
Byte-aligned widths are plain little-endian.
"""
if bits >= 8:
w = bits // 8
return [int.from_bytes(buf[i * w:(i + 1) * w], "little") for i in range(n)]
per, mask = 8 // bits, (1 << bits) - 1
return [(buf[i // per] >> (8 - bits * (i % per + 1))) & mask for i in range(n)]
def fits_region(region_size: int, n: int, bits: int) -> bool:
return _align16(-(-n * bits // 8)) == region_size
# ---- DataBlockCompression -------------------------------------------------
#
# scheme code -> (kind, bits per row, reserves code 0 for NULL, dictionary
# entry shape). Built by generating one block per (type, cardinality, nulls)
# combination with the Hyper API and reading back what it produced - see
# probe2.py. The enum itself is not decoded; this is a measured table.
#
# Two families, each {1,2,4}-bit packed and {8,16,32}-bit byte-packed, each in
# a non-null / nullable pair:
# "native" integer-like types; dictionary entries are the type's own width
# "narrow" entries are 4 B: Varchar packs u1 length + inline text (<= 3 B)
# or a u3 heap offset; Double is narrowed to float32
#
# Nullability is a property of the *block*, not of the column: Hyper picks a
# non-null scheme for a nullable column that happens to contain no NULLs.
# Reading nullability off the catalog instead produced 92 wrong columns.
# Every entry below was observed directly: probe5.py sweeps each type across a
# cardinality ladder from constant to all-distinct, which walks a column through
# single -> bit-packed dict -> byte-packed dict -> truncation -> uncompressed.
# Nothing here is interpolated. An earlier revision *did* interpolate, assuming
# each family was a contiguous triple of widths, and got 38/39 wrong: 39 is not
# the 32-bit dictionary of the {36,37} family, it is an uncompressed encoding.
#
# kind "dict" code indexes the dictionary
# "trunc" value = SMA min + code (frame of reference)
# "raw" the value itself, one per row
# "single" one value for the whole block
# "null" every row is NULL; the block carries nothing at all
# bits code width; 0 under "raw" means "the type's own width"
# entry dictionary/value shape - see read_dictionary
SCHEMES: dict[int, tuple[str, int, bool, str | None]] = {}
for _kind, _null, _entry, _codes in (
# native family: integer-like types, and Double when f32 would lose
("dict", False, "native", {1: 8, 2: 16, 24: 1, 25: 2, 26: 4}),
("dict", True, "native", {13: 8, 14: 16, 30: 1, 31: 2, 32: 4}),
("trunc", False, None, {7: 8, 8: 16, 9: 32, 52: 64}),
("trunc", True, None, {19: 8, 20: 16, 21: 32, 53: 64}),
("raw", False, "native", {10: 0}),
("raw", True, "native", {22: 0}),
# narrow family: Varchar/Char/Bytea/JSON, and Double via float32
("dict", False, "narrow", {36: 8, 37: 16, 44: 1, 45: 2, 46: 4}),
("dict", True, "narrow", {40: 8, 41: 16, 47: 1, 48: 2, 49: 4}),
("raw", False, "narrow", {39: 32}),
("raw", True, "narrow", {43: 32}),
("single", False, None, {50: 0}),
# 51 is 50's all-NULL twin: a 48-byte object that is nothing but the
# header, every offset zero, no SMA and no data. Same for every type.
# probe5.py's cardinality ladder starts at one distinct value and never
# reaches zero, which is why the table said "fully observed" without it.
("null", True, None, {51: 0})):
for _code, _bits in _codes.items():
SCHEMES[_code] = (_kind, _bits, _null, _entry)
# The enum's own names, straight out of `hyper::to_string(DataBlockCompression)`
# at 0x10125cf60: a 54-entry char* table at __DATA_CONST:0x10b4c8198, indexed by
# the code, with "INVALID" for anything >= 54. That is where the `scheme < 54`
# assertion in getPackInfoAndValidate comes from.
#
# The naming is entirely regular:
# DictN dictionary, N-BYTE codes DictBitN N-BIT codes
# TruncN truncation to N bytes Uncompressed value per row
# Single one value for the block NoSma* ... with no SMA
# ...Null the nullable twin, code 0 reserved
# Small... 4-byte entries (no prefix) 8-byte Huge... 16-byte
# so the prefix picks the entry shape and the numeral picks the code width.
SCHEME_NAMES = [
"Single", "Dict1", "Dict2", "Dict4", "HugeDict1", "HugeDict2", "HugeDict4",
"Trunc1", "Trunc2", "Trunc4", "Uncompressed", "HugeUncompressed",
"SingleNull", "Dict1Null", "Dict2Null", "Dict4Null", "HugeDict1Null",
"HugeDict2Null", "HugeDict4Null", "Trunc1Null", "Trunc2Null", "Trunc4Null",
"UncompressedNull", "HugeUncompressedNull", "DictBit1", "DictBit2",
"DictBit4", "HugeDictBit1", "HugeDictBit2", "HugeDictBit4", "DictBit1Null",
"DictBit2Null", "DictBit4Null", "HugeDictBit1Null", "HugeDictBit2Null",
"HugeDictBit4Null", "SmallDict1", "SmallDict2", "SmallDict4",
"SmallUncompressed", "SmallDict1Null", "SmallDict2Null", "SmallDict4Null",
"SmallUncompressedNull", "SmallDictBit1", "SmallDictBit2", "SmallDictBit4",
"SmallDictBit1Null", "SmallDictBit2Null", "SmallDictBit4Null",
"NoSmaSingle", "NoSmaSingleNull", "Trunc8", "Trunc8Null",
]
N_SCHEMES = len(SCHEME_NAMES) # 54, the engine's own bound
def scheme_name(code: int) -> str:
return SCHEME_NAMES[code] if 0 <= code < N_SCHEMES else "INVALID"
def _scheme_from_name(name: str) -> tuple[str, int, bool, str | None]:
"""What the enum's name says a code means. Used to audit SCHEMES."""
nullable = name.endswith("Null")
stem = name[:-4] if nullable else name
entry = ("narrow" if stem.startswith("Small") else
"huge" if stem.startswith("Huge") else "native")
stem = stem.removeprefix("Small").removeprefix("Huge")
if stem.startswith("DictBit"):
return "dict", int(stem[7:]), nullable, entry
if stem.startswith("Dict"):
return "dict", 8 * int(stem[4:]), nullable, entry
if stem.startswith("Trunc"):
return "trunc", 8 * int(stem[5:]), nullable, None
if stem == "Uncompressed":
# the cell is the value itself: its own width natively, 4 bytes narrow
return "raw", 32 if entry == "narrow" else 0, nullable, entry
if stem in ("Single", "NoSmaSingle"):
# an all-NULL block carries nothing, so it decodes without a value path
return ("null" if nullable and stem == "NoSmaSingle" else "single",
0, nullable, None)
raise ValueError(name)
# Every code measured by probe5/probe6/probe8/probe9 must agree with what its
# name says. It does, for all 34 - an independent confirmation of a table that
# was built entirely from observation, and of scheme 51 in particular:
# NoSmaSingleNull is exactly the all-NULL block the probe found.
SCHEMES_MEASURED = frozenset(SCHEMES)
for _code, _spec in SCHEMES.items():
_want = _scheme_from_name(SCHEME_NAMES[_code])
assert _spec == _want, (_code, SCHEME_NAMES[_code], _spec, _want)
# The remaining codes are filled in from their names. This is not the
# interpolation that §9.18 retracted - that guessed the code *numbering* from a
# pattern; these come from the engine's own name table, decoded by a rule the 34
# measured codes agree with. Only the code width and nullability vary, and the
# decoder is generic in both.
#
# `Huge*` is deliberately left out. Its name says the dictionary entry is a
# third, wider shape, and 16 bytes is the obvious guess, but nothing has been
# measured - so those 14 codes still make the decoder refuse rather than invent
# a layout. See §10.
for _code, _name in enumerate(SCHEME_NAMES):
if _code in SCHEMES or _name.startswith("Huge"):
continue
SCHEMES[_code] = _scheme_from_name(_name)
def null_sentinel(cell: bytes, ct: ColType, entry: str | None) -> bool:
"""Is this raw cell the NULL marker?
"raw" has no spare code, so NULL is the **maximum value of the storage
representation**: 0x7FFF for an i16, +Inf for f32/f64, 0xFFFFFFFF for the
4-byte narrow entry. Confirmed for SmallInt, Float, Double, Varchar, Char,
Bytea and JSON; the unsigned-counter types (Date, Time, Timestamp, Oid)
have no nullable-raw case in the corpus, so their sentinel is the analogous
all-ones and is unverified.
"""
w = len(cell)
if ct.is_variable:
# Both string cell shapes use an all-ones cell for NULL - 0xFFFFFFFF
# for the 4-byte narrow one, 0xFFFFFFFFFFFFFFFF for the 8-byte wide.
return cell == b"\xff" * w