Module refinery.lib.access

Parser for Microsoft Access database files (.mdb / .accdb). Supported are:

Type Program
Jet3 Access 97
Jet4 Access 2000/2002/2003
ACE12 Access 2007
ACE14 Access 2010+
Expand source code Browse git
"""
Parser for Microsoft Access database files (.mdb / .accdb). Supported are:

|  Type | Program               |
|------:|:----------------------|
|  Jet3 | Access 97             |
|  Jet4 | Access 2000/2002/2003 |
| ACE12 | Access 2007           |
| ACE14 | Access 2010+          |
"""
from __future__ import annotations

import codecs
import enum
import math
import re
import struct

from collections import defaultdict
from datetime import datetime, timedelta
from typing import Literal, NamedTuple, overload
from uuid import UUID

from refinery.lib.structures import StructReader

_ACCESS_EPOCH = datetime(1899, 12, 30)

_TABLE_MAGIC = b'\x02\x01'
_DATA_MAGIC = b'\x01\x01'

_SYSTEM_FLAGS = {-0x80000000, -2, 0x80000000, 2}

_ACCESS_MAGIC = b'\x00\x01\x00\x00'
_ACCESS_ENGINES = (b'Standard ACE DB', b'Standard Jet DB')

_OLE_MAGIC = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'

_USER_PROFILE_PATH = re.compile(r'(?i)^[A-Za-z]:[\\/]Users[\\/][^\\/]+')

_VBA_PROJECT_RESERVED1 = 0x61CC
_VBA_REFERENCE_SENTINEL = '*\\'.encode('utf-16-le')

_IMEXSPEC_STREAM = 'Blob'
_IMEXSPEC_ROOT = 'ImportExportSpecification'


class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str


def _parse_libid(text: str) -> VbaReference | None:
    """
    Parse a single LIBID string of the form `*\\G{guid}#version#lcid#path#description` into its
    fields. The leading `*\\G` marker and the surrounding braces of the GUID are stripped. Returns
    None when the string does not have the expected shape.
    """
    if not text.startswith('*\\'):
        return None
    body = text[3:]
    if not body.startswith('{'):
        return None
    fields = body.split('#')
    if len(fields) < 4:
        return None
    guid = fields[0].strip('{}')
    version = fields[1]
    lcid = fields[2]
    path = fields[3]
    description = fields[4] if len(fields) > 4 else ''
    return VbaReference(guid, version, lcid, path, description)


def _parse_vba_references(stream: bytes) -> list[VbaReference]:
    """
    Parse the reference table embedded in the PerformanceCache of a `_VBA_PROJECT` stream. The
    stream begins with the version-independent header described in MS-OVBA §2.3.4.1, whose first
    field Reserved1 must equal 0x61CC. The PerformanceCache that follows is implementation specific
    and undocumented, but it stores each type-library reference as a length-prefixed LIBID record: a
    16-bit little-endian byte count immediately precedes a UTF-16LE LIBID string. Each reference is
    located by its `*\\` sentinel and accepted only when the preceding length prefix exactly bounds
    a well formed LIBID string, which distinguishes a genuine record from a coincidental byte match.
    """
    if len(stream) < 7 or int.from_bytes(stream[:2], 'little') != _VBA_PROJECT_RESERVED1:
        return []
    references: list[VbaReference] = []
    search = 0
    while True:
        pos = stream.find(_VBA_REFERENCE_SENTINEL, search)
        if pos < 0:
            break
        search = pos + 2
        if pos < 2:
            continue
        length = int.from_bytes(stream[pos - 2:pos], 'little')
        end = pos + length
        if length == 0 or length % 2 or end > len(stream):
            continue
        segment = stream[pos:end]
        if any(segment[i] == 0 and segment[i + 1] == 0 for i in range(0, len(segment) - 1, 2)):
            continue
        text = segment.decode('utf-16-le', 'replace')
        if '�' in text:
            continue
        reference = _parse_libid(text)
        if reference is not None:
            references.append(reference)
            search = end
    return references


def _parse_import_export_spec(blob: bytes) -> str | None:
    """
    Parse the path recorded in an Access import/export specification. Each specification is stored
    as a UTF-16LE XML document whose root `ImportExportSpecification` element carries a `Path`
    attribute naming the file that was imported or exported on the machine that authored the
    database. The XML declaration advertises a `utf-8` encoding that does not match the actual
    UTF-16LE byte stream, so the document is decoded explicitly before parsing. Any bytes that
    trail the closing tag are ignored so that stream padding does not defeat the decode. Returns the
    path or None when the blob is not a well formed specification.
    """
    from refinery.lib.xml import ForgivingParse

    closing = F'</{_IMEXSPEC_ROOT}>'.encode('utf-16-le')
    end = blob.find(closing)
    if end < 0:
        return None
    document = blob[:end + len(closing)]
    try:
        text = document.decode('utf-16-le')
    except UnicodeDecodeError:
        return None
    try:
        root = ForgivingParse(text.encode('utf-8')).getroot()
    except Exception:
        return None
    tag = root.tag.rsplit('}', 1)[-1]
    if tag != _IMEXSPEC_ROOT:
        return None
    return root.get('Path') or None


def is_access_database(data: bytes | bytearray | memoryview) -> bool:
    """
    Check whether the given data begins with the magic of a Microsoft Access database (.mdb or
    .accdb), i.e. the Jet or ACE database engine signature.
    """
    return data[:4] == _ACCESS_MAGIC and bytes(data[4:19]) in _ACCESS_ENGINES


class JetVersion(enum.IntEnum):
    V3 = 0x00
    V4 = 0x01
    V5 = 0x02
    V2010 = 0x03


_ENGINE_NAMES = {
    JetVersion.V3: 'Jet3',
    JetVersion.V4: 'Jet4',
    JetVersion.V5: 'ACE12',
    JetVersion.V2010: 'ACE14',
}


class ColumnType(enum.IntEnum):
    BOOLEAN = 1
    INT8 = 2
    INT16 = 3
    INT32 = 4
    MONEY = 5
    FLOAT32 = 6
    FLOAT64 = 7
    DATETIME = 8
    BINARY = 9
    TEXT = 10
    OLE = 11
    MEMO = 12
    GUID = 15
    NUMERIC = 16
    COMPLEX = 18


class _Column(NamedTuple):
    type: int
    column_id: int
    variable_column_number: int
    column_index: int
    fixed_length: bool
    fixed_offset: int
    length: int
    name: str = ''
    collation: int | None = None
    code_page: int | None = None
    precision: int | None = None
    scale: int | None = None


class _VarLenMeta(NamedTuple):
    field_count: int
    field_offsets: list[int]
    var_len_count: int
    jump_table: list[int]
    metadata_end: int


def _mdb_date(raw: int) -> datetime | None:
    try:
        value, = struct.unpack('<d', struct.pack('<Q', raw))
        frac, whole = math.modf(value)
        result = _ACCESS_EPOCH + timedelta(days=whole) + timedelta(days=frac)
        if result == _ACCESS_EPOCH:
            return None
        return result
    except (OverflowError, ValueError, struct.error):
        return None


def _numeric_to_string(data: bytes | memoryview, scale: int = 6) -> str:
    neg, n1, n2, n3, n4 = struct.unpack_from('<BIIII', data)
    full = (n1 << 96) + (n2 << 64) + (n3 << 32) + n4
    digits = str(full)
    if len(digits) > scale:
        dot = len(digits) - scale
        digits = F'{digits[:dot]}.{digits[dot:]}'
    return F'-{digits}' if neg else digits


def _decode_text(data: bytes | memoryview) -> str:
    try:
        return codecs.decode(data, 'utf-8')
    except (UnicodeDecodeError, ValueError):
        return codecs.decode(data, 'latin-1')


@overload
def _parse_type(column_type: Literal[
    ColumnType.INT8,
    ColumnType.INT16,
    ColumnType.INT32,
    ColumnType.MONEY,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> int:
    pass


@overload
def _parse_type(column_type: Literal[
    ColumnType.FLOAT32,
    ColumnType.FLOAT64,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> float:
    pass


@overload
def _parse_type(column_type: Literal[
    ColumnType.OLE,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> bytes:
    pass


@overload
def _parse_type(column_type: Literal[
    ColumnType.TEXT,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> str:
    pass


@overload
def _parse_type(column_type: Literal[
    ColumnType.DATETIME,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> datetime | None:
    pass


@overload
def _parse_type(column_type: Literal[
    ColumnType.GUID,
], data: bytes | memoryview, length: int | None = None, is_v3: bool = True) -> UUID:
    pass


@overload
def _parse_type(
    column_type: int, data: bytes | memoryview, length: int | None = None, is_v3: bool = True
) -> int | float | str | bytes | datetime | UUID | None:
    pass


def _parse_type(
    column_type: int,
    data: bytes | memoryview,
    length: int | None = None,
    is_v3: bool = True,
) -> int | float | str | bytes | datetime | UUID | None:
    if column_type == ColumnType.INT8:
        return struct.unpack_from('b', data)[0]
    if column_type == ColumnType.INT16:
        return struct.unpack_from('<h', data)[0]
    if column_type in (ColumnType.INT32, ColumnType.COMPLEX):
        return struct.unpack_from('<i', data)[0]
    if column_type == ColumnType.MONEY:
        return struct.unpack_from('<q', data)[0]
    if column_type == ColumnType.FLOAT32:
        return struct.unpack_from('<f', data)[0]
    if column_type == ColumnType.FLOAT64:
        return struct.unpack_from('<d', data)[0]
    if column_type == ColumnType.DATETIME:
        raw = struct.unpack_from('<Q', data)[0]
        return _mdb_date(raw)
    if column_type == ColumnType.BINARY:
        if length is not None:
            return bytes(data[:length])
        return bytes(data)
    if column_type == ColumnType.OLE:
        return bytes(data)
    if column_type == ColumnType.GUID:
        return UUID(bytes_le=bytes(data[:16]))
    if column_type == ColumnType.NUMERIC:
        return bytes(data[:17])
    if column_type == ColumnType.TEXT:
        if not is_v3:
            if data[:2] in (b'\xfe\xff', b'\xff\xfe'):
                text = _decode_text(data[2:])
            else:
                text = codecs.decode(data, 'utf-16-le', errors='ignore')
        else:
            text = _decode_text(data)
        return text.replace('\x00', '')
    if length is not None:
        return bytes(data[:length])
    return bytes(data)


def _parse_data_page_header(reader: StructReader[memoryview], is_v3: bool) -> tuple[int, list[int]]:
    magic = reader.read(2)
    if bytes(magic) != _DATA_MAGIC:
        raise ValueError('invalid data page magic')
    reader.u16()
    owner = reader.u32()
    if not is_v3:
        reader.u32()
    record_count = reader.u16()
    offsets = [reader.u16() for _ in range(record_count)]
    return owner, offsets


def _parse_tdef_header(reader: StructReader[memoryview]) -> tuple[int, int]:
    magic = reader.read(2)
    if bytes(magic) != _TABLE_MAGIC:
        raise ValueError('invalid table definition magic')
    reader.u16()
    next_page = reader.u32()
    header_end = reader.tell()
    return next_page, header_end


class _TableHead(NamedTuple):
    next_page: int
    row_count: int
    variable_columns: int
    column_count: int
    index_count: int
    real_index_count: int
    header_end: int


def _parse_table_head(reader: StructReader[memoryview], is_v3: bool) -> _TableHead:
    next_page, _ = _parse_tdef_header(reader)
    reader.skip(4 if is_v3 else 8)
    row_count = reader.u32()
    reader.skip(7 if is_v3 else 23)
    variable_columns = reader.u16()
    column_count = reader.u16()
    index_count = reader.u32()
    real_index_count = reader.u32()
    reader.skip(8)
    header_end = reader.tell()
    return _TableHead(
        next_page=next_page,
        row_count=row_count,
        variable_columns=variable_columns,
        column_count=column_count,
        index_count=index_count,
        real_index_count=real_index_count,
        header_end=header_end,
    )


def _parse_columns(
    reader: StructReader[memoryview],
    column_count: int,
    real_index_count: int,
    index_count: int,
    is_v3: bool,
) -> list[_Column]:
    for _ in range(real_index_count):
        reader.skip(12 if not is_v3 else 8)

    raw_columns = []
    for _ in range(column_count):
        col_type = reader.u8()
        if not is_v3:
            reader.skip(4)
        col_id = reader.u16()
        var_col_num = reader.u16()
        col_index = reader.u16()

        collation: int | None = None
        code_page: int | None = None
        precision: int | None = None
        scale: int | None = None
        if col_type in (
            ColumnType.BINARY,
            ColumnType.TEXT,
            ColumnType.OLE,
            ColumnType.MEMO,
        ):
            if is_v3:
                collation = reader.u16()
                code_page = reader.u16()
                reader.skip(2)
            else:
                collation = reader.u16()
                reader.skip(2)
        elif col_type == ColumnType.NUMERIC:
            precision = reader.u8()
            scale = reader.u8()
            reader.skip(4 if is_v3 else 2)
        elif col_type in (
            ColumnType.BOOLEAN,
            ColumnType.INT8,
            ColumnType.INT16,
            ColumnType.INT32,
            ColumnType.MONEY,
            ColumnType.FLOAT32,
            ColumnType.FLOAT64,
            ColumnType.DATETIME,
        ):
            reader.skip(6 if is_v3 else 4)
        else:
            reader.skip(6 if is_v3 else 4)

        flags_byte = reader.u8()
        fixed_length = bool(flags_byte & 0x01)
        if not is_v3:
            reader.skip(5)

        fixed_offset = reader.u16()
        length = reader.u16()
        raw_columns.append(_Column(
            type=col_type,
            column_id=col_id,
            variable_column_number=var_col_num,
            column_index=col_index,
            fixed_length=fixed_length,
            fixed_offset=fixed_offset,
            length=length,
            collation=collation,
            code_page=code_page,
            precision=precision,
            scale=scale,
        ))

    columns = []
    for col in raw_columns:
        if is_v3:
            name_len = reader.u8()
            name = codecs.decode(reader.read(name_len), 'utf-8', errors='replace')
        else:
            name_len = reader.u16()
            name = codecs.decode(reader.read(name_len), 'utf-16-le', errors='replace')
        columns.append(col._replace(name=name))

    for _ in range(real_index_count):
        reader.skip(52 if not is_v3 else 39)

    for _ in range(index_count):
        reader.skip(28 if not is_v3 else 20)

    return columns


def _parse_var_length_metadata(
    reverse_data: memoryview,
    is_v3: bool,
    jump_table_count: int = 0,
) -> _VarLenMeta | None:
    reader = StructReader[memoryview](reverse_data)
    reader.bigendian = True
    try:
        if is_v3:
            field_count = reader.u8()
            jump_table = [reader.u8() for _ in range(jump_table_count)]
            offsets = [reader.u8() for _ in range(field_count)]
            var_len_count = reader.u8()
        else:
            field_count = reader.u16()
            jump_table = []
            count = field_count & 0xFF
            offsets = [reader.u16() for _ in range(count)]
            var_len_count = reader.u16()
        return _VarLenMeta(
            field_count=field_count,
            field_offsets=offsets,
            var_len_count=var_len_count,
            jump_table=jump_table,
            metadata_end=reader.tell(),
        )
    except Exception:
        return None


class _TableObj:

    __slots__ = 'offset', 'value', 'linked_pages'

    def __init__(self, offset: int, value: memoryview, /):
        self.offset = offset
        self.value = value
        self.linked_pages: list[memoryview] = []


class AccessDatabase:
    """
    Parser for Microsoft Access database files. Accepts raw bytes as input and
    provides a `catalog` mapping table names to IDs and a `parse_table` method
    that returns `dict[str, list]` (column name to list of row values).
    """

    def __init__(self, data: bytes | bytearray | memoryview):
        mv = memoryview(data)
        self._data = mv
        self._parse_header()
        self._table_defs, self._data_pages = self._categorize_pages()
        self._tables_with_data = self._link_tables_to_data()
        self.catalog: dict[str, int] = self._parse_catalog()

    def _parse_header(self):
        reader = StructReader[memoryview](self._data)
        magic = reader.read(4)
        if bytes(magic) != b'\x00\x01\x00\x00':
            raise ValueError('not a valid Access database file')
        while reader.u8():
            pass
        raw_version = reader.u32()
        try:
            version = JetVersion(raw_version)
        except ValueError:
            version = JetVersion.V3
        self._version = version
        self._is_v3 = version == JetVersion.V3
        self._page_size = 0x800 if self._is_v3 else 0x1000

    def _categorize_pages(self):
        table_defs: dict[int, memoryview] = {}
        data_pages: dict[int, memoryview] = {}
        ps = self._page_size
        data = self._data
        for offset in range(0, len(data), ps):
            page = data[offset:offset + ps]
            if len(page) < 2:
                continue
            sig = bytes(page[:2])
            if sig == _TABLE_MAGIC:
                table_defs[offset] = page
            elif sig == _DATA_MAGIC:
                data_pages[offset] = page
        return table_defs, data_pages

    def _link_tables_to_data(self) -> dict[int, _TableObj]:
        tables: dict[int, _TableObj] = {}
        ps = self._page_size
        for offset, page in self._data_pages.items():
            try:
                reader = StructReader[memoryview](page)
                owner, _ = _parse_data_page_header(reader, self._is_v3)
            except Exception:
                continue
            page_offset = owner * ps
            if page_offset not in self._table_defs:
                continue
            if page_offset not in tables:
                tables[page_offset] = _TableObj(page_offset, self._table_defs[page_offset])
            tables[page_offset].linked_pages.append(page)
        return tables

    def _parse_catalog(self) -> dict[str, int]:
        catalog_offset = 2 * self._page_size
        if catalog_offset not in self._tables_with_data:
            return {}
        catalog_table = self._tables_with_data[catalog_offset]
        parsed = self._do_parse_table(catalog_table)
        if not parsed:
            return {}
        names = parsed.get('Name', [])
        ids = parsed.get('Id', [])
        types = parsed.get('Type', [])
        flags = parsed.get('Flags', [])
        mapping: dict[str, int] = {}
        for i, name in enumerate(names):
            if not isinstance(name, str):
                continue
            if name == 'MSysObjects':
                if i < len(ids):
                    mapping[name] = ids[i]
                continue
            if i < len(types) and types[i] == 1:
                if i < len(flags) and flags[i] not in _SYSTEM_FLAGS:
                    if i < len(ids):
                        mapping[name] = ids[i]
        return mapping

    def parse_table(self, name: str) -> dict[str, list]:
        """
        Parse a table by name. Returns a dictionary mapping column names to
        lists of row values.
        """
        table_id = self.catalog.get(name)
        if table_id is None:
            return {}
        table_offset = table_id * self._page_size
        table_obj = self._tables_with_data.get(table_offset)
        if table_obj is None:
            table_def = self._table_defs.get(table_offset)
            if table_def is not None:
                table_obj = _TableObj(table_offset, table_def)
            else:
                return {}
        return self._do_parse_table(table_obj)

    def _parse_system_table(self, name: str) -> dict[str, list]:
        """
        Parse a system table by name. Access keeps system tables such as MSysAccessStorage out of
        the user-facing `catalog`, so this resolves the table id from MSysObjects when necessary.
        """
        if name in self.catalog:
            return self.parse_table(name)
        objects = self.parse_table('MSysObjects')
        names = objects.get('Name', [])
        ids = objects.get('Id', [])
        for i, candidate in enumerate(names):
            if candidate == name and i < len(ids):
                table_offset = ids[i] * self._page_size
                table_obj = self._tables_with_data.get(table_offset)
                if table_obj is None:
                    table_def = self._table_defs.get(table_offset)
                    if table_def is None:
                        return {}
                    table_obj = _TableObj(table_offset, table_def)
                return self._do_parse_table(table_obj)
        return {}

    def open_vba(self):
        """
        Reconstruct the VBA project storage tree and return it through the interface of
        `refinery.lib.ole.file.OleFile`. Microsoft Access stores VBA in one of two containers: newer
        databases explode the project into individual rows of the MSysAccessStorage system table,
        while older databases embed a complete OLE2 compound file in the MSysAccessObjects system
        table. Both are tried in turn. Returns None when no VBA project is present.
        """
        return self._open_vba_storage() or self._open_vba_objects()

    def _open_vba_storage(self):
        """
        Reconstruct the VBA project from the MSysAccessStorage system table, where each stream of
        the project is stored as an individual row and the storage hierarchy is encoded by the
        ParentId column. Returns a `refinery.lib.ole.file.VirtualOleFile` or None.
        """
        from refinery.lib.ole.file import STGTY, VirtualOleFile

        table = self._parse_system_table('MSysAccessStorage')
        ids = table.get('Id')
        if not ids:
            return None
        names = table.get('Name', [])
        parents = table.get('ParentId', [])
        types = table.get('Type', [])
        values = table.get('Lv', [])

        name_of: dict[int, str] = {}
        parent_of: dict[int, int] = {}
        for i, node_id in enumerate(ids):
            name = names[i] if i < len(names) else None
            if not isinstance(name, str):
                continue
            name_of[node_id] = name
            parent = parents[i] if i < len(parents) else None
            if isinstance(parent, int):
                parent_of[node_id] = parent

        def full_path(node_id: int) -> str | None:
            parts = [name_of[node_id]]
            seen = {node_id}
            parent = parent_of.get(node_id)
            while parent is not None and parent in name_of and parent not in seen:
                seen.add(parent)
                parent_name = name_of[parent]
                if parent_name.endswith('_ROOT') or parent_name.endswith('_SCRATCH'):
                    break
                parts.append(parent_name)
                parent = parent_of.get(parent)
            return '/'.join(reversed(parts))

        entries: list[tuple[str, int, bytes | None]] = []
        for i, node_id in enumerate(ids):
            name = name_of.get(node_id)
            if name is None or name.endswith('_ROOT') or name.endswith('_SCRATCH'):
                continue
            path = full_path(node_id)
            if not path:
                continue
            kind = types[i] if i < len(types) else None
            value = values[i] if i < len(values) else None
            if kind == 1:
                entries.append((path, STGTY.STORAGE, None))
            elif kind == 2 and isinstance(value, (bytes, bytearray, memoryview)):
                entries.append((path, STGTY.STREAM, bytes(value)))

        if not entries:
            return None
        return VirtualOleFile(entries)

    def _open_vba_objects(self):
        """
        Reconstruct the VBA project from the MSysAccessObjects system table, which stores a complete
        OLE2 compound file split across the rows of a single binary column. The rows are ordered by
        their ID column and concatenated to recover the compound file. Returns a
        `refinery.lib.ole.file.OleFile` or None.
        """
        from refinery.lib.ole.file import NotOleFileError, OleFile, OleFileError

        table = self._parse_system_table('MSysAccessObjects')
        ids = table.get('ID')
        data = table.get('Data')
        if not ids or not data:
            return None

        rows = sorted(
            zip(ids, data),
            key=lambda row: row[0] if isinstance(row[0], int) else 0,
        )
        blob = b''.join(
            bytes(value) for _, value in rows
            if isinstance(value, (bytes, bytearray, memoryview))
        )
        start = blob.find(_OLE_MAGIC)
        if start < 0:
            return None
        try:
            return OleFile(blob[start:])
        except (OleFileError, NotOleFileError, ValueError):
            return None

    def vba_references(self, vba=None) -> list[VbaReference]:
        """
        Return the type-library references declared by every VBA project in the database. Each
        `_VBA_PROJECT` stream is located through the reconstructed storage tree and its reference
        table is parsed structurally. A reconstructed storage tree may be passed in to avoid
        opening it twice; otherwise it is opened on demand.
        """
        if vba is None:
            try:
                vba = self.open_vba()
            except Exception:
                vba = None
        if vba is None:
            return []
        references: list[VbaReference] = []
        for entry in vba.listdir(streams=True):
            if entry[-1] != '_VBA_PROJECT':
                continue
            try:
                stream = bytes(vba.openstream('/'.join(entry)).read())
            except Exception:
                continue
            references.extend(_parse_vba_references(stream))
        return references

    def import_export_paths(self, vba=None) -> list[str]:
        """
        Return the file paths recorded in the import/export specifications of the database. These
        specifications are stored as XML blobs in the reconstructed storage tree and name a file on
        the machine that authored the database. A reconstructed storage tree may be passed in to
        avoid opening it twice; otherwise it is opened on demand.
        """
        if vba is None:
            try:
                vba = self.open_vba()
            except Exception:
                vba = None
        if vba is None:
            return []
        paths: dict[str, None] = {}
        for entry in vba.listdir(streams=True):
            if entry[-1] != _IMEXSPEC_STREAM:
                continue
            try:
                blob = bytes(vba.openstream('/'.join(entry)).read())
            except Exception:
                continue
            path = _parse_import_export_spec(blob)
            if path is not None:
                paths[path] = None
        return list(paths)

    def metadata(self) -> dict:
        """
        Extract triage metadata from the database. The result contains the engine name, the
        earliest creation and latest modification timestamps, the user profile paths leaked by the
        VBA project reference table (the machine that compiled the VBA project), and every file path
        recorded in the import/export specifications (the data sources on the machine that authored
        the database). Each path field is included only when its source yields at least one path.
        This method is best effort and never raises.
        """
        result: dict = {
            'Engine': _ENGINE_NAMES.get(self._version, str(self._version))
        }
        try:
            objects = self._parse_system_table('MSysObjects')
        except Exception:
            pass
        else:
            result['Created'] = min(
                (c for c in objects.get('DateCreate', []) if isinstance(c, datetime)), default=None)
            result['Updated'] = max(
                (u for u in objects.get('DateUpdate', []) if isinstance(u, datetime)), default=None)
        try:
            vba = self.open_vba()
        except Exception:
            vba = None
        else:
            if p := self._distinct(self.import_export_paths(vba)):
                result['ImportExportPaths'] = p
            if p := self._profile_paths(reference.path for reference in self.vba_references(vba)):
                result['VBAProfilePaths'] = p
        return result

    @staticmethod
    def _distinct(paths) -> list[str]:
        """
        Reduce an iterable of file paths to a sorted list of the distinct string paths.
        """
        found = {path for path in paths if isinstance(path, str)}
        return sorted(found)

    @staticmethod
    def _profile_paths(paths) -> list[str]:
        """
        Filter an iterable of file paths down to the sorted, distinct paths that lie within a user
        profile directory.
        """
        found = {path for path in paths if isinstance(path, str) and _USER_PROFILE_PATH.match(path)}
        return sorted(found)

    def _do_parse_table(self, table_obj: _TableObj) -> dict[str, list]:
        try:
            columns, variable_columns, column_count = self._get_table_columns(table_obj)
        except Exception:
            return {}
        if not columns:
            return {}

        parsed: dict[str, list] = defaultdict(list)

        if not table_obj.linked_pages:
            for col in columns.values():
                parsed[col.name] = []
            return dict(parsed)

        for data_chunk in table_obj.linked_pages:
            try:
                reader = StructReader[memoryview](data_chunk)
                _, record_offsets = _parse_data_page_header(reader, self._is_v3)
            except Exception:
                continue

            last_offset: int | None = None
            for rec_offset in record_offsets:
                if rec_offset & 0x8000:
                    last_offset = rec_offset & 0xFFF
                    continue
                if rec_offset & 0x4000:
                    ptr_offset = rec_offset & 0xFFF
                    last_offset = ptr_offset
                    if ptr_offset + 4 <= len(data_chunk):
                        overflow_ptr = struct.unpack_from('<I', data_chunk, ptr_offset)[0]
                        record = self._get_overflow_record(overflow_ptr)
                        if record:
                            self._parse_row(
                                record, columns, variable_columns, column_count, parsed)
                    continue

                if last_offset is None:
                    record = data_chunk[rec_offset:]
                else:
                    record = data_chunk[rec_offset:last_offset]
                last_offset = rec_offset
                if record:
                    self._parse_row(
                        record, columns, variable_columns, column_count, parsed)

        return dict(parsed)

    def _get_table_columns(self, table_obj: _TableObj):
        reader = StructReader[memoryview](table_obj.value)
        head = _parse_table_head(reader, self._is_v3)

        merged = table_obj.value[head.header_end:]
        if head.next_page:
            extra = self._merge_tdef_pages(head.next_page)
            merged = memoryview(bytes(merged) + bytes(extra))

        col_reader = StructReader[memoryview](merged)
        columns_list = _parse_columns(
            col_reader, head.column_count, head.real_index_count,
            head.index_count, self._is_v3)

        offset = min(c.column_index for c in columns_list) if columns_list else 0
        column_dict = {c.column_index - offset: c for c in columns_list}
        if len(column_dict) != len(columns_list):
            column_dict = {c.column_id: c for c in columns_list}

        return column_dict, head.variable_columns, head.column_count

    def _merge_tdef_pages(self, first_page_num: int) -> memoryview:
        parts = bytearray()
        page_data = self._table_defs.get(first_page_num * self._page_size)
        if page_data is None:
            return memoryview(parts)
        reader = StructReader[memoryview](page_data)
        next_page, header_end = _parse_tdef_header(reader)
        parts.extend(page_data[header_end:])
        while next_page:
            page_data = self._table_defs.get(next_page * self._page_size)
            if page_data is None:
                break
            reader = StructReader[memoryview](page_data)
            next_page, header_end = _parse_tdef_header(reader)
            parts.extend(page_data[header_end:])
        return memoryview(parts)

    def _parse_row(
        self,
        record: memoryview,
        columns: dict[int, _Column],
        variable_columns_count: int,
        column_count: int,
        parsed: dict[str, list],
    ):
        if len(record) < 1:
            return
        null_table_len = (column_count + 7) // 8
        if null_table_len >= len(record):
            return
        null_bytes = record[-null_table_len:]
        null_table = [
            ((null_bytes[i // 8]) & (1 << (i % 8))) != 0
            for i in range(null_table_len * 8)
        ]

        if not self._is_v3:
            if len(record) < 2:
                return
            row_data = record[2:]
        else:
            row_data = record[1:]

        var_columns: dict[int, _Column] = {}
        for i, column in columns.items():
            if not column.fixed_length:
                var_columns[i] = column
                continue
            col_name = column.name
            has_value = True
            if column.column_id < len(null_table):
                has_value = null_table[column.column_id]
            if column.type == ColumnType.BOOLEAN:
                parsed[col_name].append(has_value)
                continue
            if not has_value:
                parsed[col_name].append(None)
                continue
            if column.fixed_offset >= len(row_data):
                parsed[col_name].append(None)
                continue
            field_data = row_data[column.fixed_offset:]
            value = _parse_type(column.type, field_data, column.length, self._is_v3)
            parsed[col_name].append(value)

        if not var_columns:
            return

        var_columns = dict(sorted(var_columns.items()))
        reverse_record = record[::-1]
        reverse_after_null = reverse_record[null_table_len:]

        if self._is_v3:
            jump_table_count = (len(record) - 1) // 256
        else:
            jump_table_count = 0

        metadata = _parse_var_length_metadata(
            memoryview(reverse_after_null), self._is_v3, jump_table_count)

        if metadata is None:
            return

        if self._is_v3 and metadata.field_count != variable_columns_count:
            search_byte = variable_columns_count & 0xFF
            pos = bytes(reverse_after_null).find(bytes([search_byte]))
            if pos != -1 and pos < 10:
                adjusted = memoryview(reverse_after_null[pos:])
                metadata = _parse_var_length_metadata(
                    adjusted, self._is_v3, jump_table_count)
                if metadata is not None:
                    metadata = metadata._replace(
                        metadata_end=metadata.metadata_end + pos)
            else:
                return

        if not metadata or not metadata.field_offsets:
            return

        offsets = metadata.field_offsets
        jump_addition = 0
        for i, col_index in enumerate(var_columns):
            column = var_columns[col_index]
            col_name = column.name
            has_value = True
            if column.column_id < len(null_table):
                has_value = null_table[column.column_id]
            if not has_value:
                parsed[col_name].append(None)
                continue

            if self._is_v3 and i in metadata.jump_table:
                jump_addition += 0x100

            if i >= len(offsets):
                parsed[col_name].append(None)
                continue

            rel_start = offsets[i]
            if i + 1 < len(offsets):
                rel_end = offsets[i + 1]
            else:
                rel_end = metadata.var_len_count

            if rel_start == rel_end:
                parsed[col_name].append('')
                continue

            field_data = record[rel_start + jump_addition:rel_end + jump_addition]

            if column.type == ColumnType.MEMO:
                try:
                    value = self._parse_memo(field_data, raw=False)
                except Exception:
                    value = bytes(field_data)
            elif column.type == ColumnType.OLE:
                try:
                    value = self._parse_memo(field_data, raw=True)
                except Exception:
                    value = bytes(field_data)
            elif column.type == ColumnType.NUMERIC:
                if len(field_data) == 17:
                    scale = column.scale if column.scale is not None else 6
                    value = _numeric_to_string(field_data, scale)
                else:
                    value = bytes(field_data)
            else:
                value = _parse_type(column.type, field_data, len(field_data), self._is_v3)
            parsed[col_name].append(value)

    def _parse_memo(self, data: memoryview, raw: bool = False):
        if len(data) < 12:
            return bytes(data)
        memo_length = struct.unpack_from('<I', data, 0)[0]
        record_pointer = struct.unpack_from('<I', data, 4)[0]
        memo_end = 12

        if memo_length & 0x80000000:
            inline_length = memo_length & 0x3FFFFFFF
            if len(data) < memo_end + inline_length:
                memo_data = data[memo_end:]
            else:
                memo_data = data[memo_end:memo_end + inline_length]
        elif memo_length & 0x40000000:
            result = self._get_overflow_record(record_pointer)
            if result is None:
                return None
            memo_data = result
        else:
            rec_data = self._get_overflow_record(record_pointer)
            if rec_data is None:
                return None
            next_page = struct.unpack_from('<I', rec_data, 0)[0]
            parts = bytearray()
            while next_page:
                parts.extend(rec_data[4:])
                rec_data = self._get_overflow_record(next_page)
                if rec_data is None:
                    break
                next_page = struct.unpack_from('<I', rec_data, 0)[0]
            if rec_data is not None:
                parts.extend(rec_data[4:])
            memo_data = memoryview(parts)

        if not memo_data:
            return None
        if raw:
            return bytes(memo_data)
        return _parse_type(ColumnType.TEXT, memo_data, len(memo_data), self._is_v3)

    def _get_overflow_record(self, record_pointer: int) -> memoryview | None:
        record_offset = record_pointer & 0xFF
        page_num = record_pointer >> 8
        page = self._data_pages.get(page_num * self._page_size)
        if page is None:
            return None
        try:
            reader = StructReader[memoryview](page)
            _, offsets = _parse_data_page_header(reader, self._is_v3)
        except Exception:
            return None
        if record_offset >= len(offsets):
            return None
        start = offsets[record_offset]
        if start & 0x8000:
            start = start & 0xFFF
        if record_offset == 0:
            return page[start:]
        else:
            end = offsets[record_offset - 1]
            if end & 0x8000 and (end & 0xFF != 0):
                end = end & 0xFFF
            return page[start:end]

Functions

def is_access_database(data)

Check whether the given data begins with the magic of a Microsoft Access database (.mdb or .accdb), i.e. the Jet or ACE database engine signature.

Expand source code Browse git
def is_access_database(data: bytes | bytearray | memoryview) -> bool:
    """
    Check whether the given data begins with the magic of a Microsoft Access database (.mdb or
    .accdb), i.e. the Jet or ACE database engine signature.
    """
    return data[:4] == _ACCESS_MAGIC and bytes(data[4:19]) in _ACCESS_ENGINES

Classes

class VbaReference (guid, version, lcid, path, description)

A single type-library reference parsed from the reference table of a VBA project. The fields follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute path of the referenced type library on the machine that compiled the project, and a human readable description.

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str

Ancestors

  • builtins.tuple

Instance variables

var guid

Alias for field number 0

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str
var version

Alias for field number 1

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str
var lcid

Alias for field number 2

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str
var path

Alias for field number 3

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str
var description

Alias for field number 4

Expand source code Browse git
class VbaReference(NamedTuple):
    """
    A single type-library reference parsed from the reference table of a VBA project. The fields
    follow the LIBID grammar defined in MS-OVBA §2.1.1.8: a GUID, a version, an LCID, the absolute
    path of the referenced type library on the machine that compiled the project, and a human
    readable description.
    """
    guid: str
    version: str
    lcid: str
    path: str
    description: str
class JetVersion (*args, **kwds)

Enum where members are also (and must be) ints

Expand source code Browse git
class JetVersion(enum.IntEnum):
    V3 = 0x00
    V4 = 0x01
    V5 = 0x02
    V2010 = 0x03

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var V3

The type of the None singleton.

var V4

The type of the None singleton.

var V5

The type of the None singleton.

var V2010

The type of the None singleton.

class ColumnType (*args, **kwds)

Enum where members are also (and must be) ints

Expand source code Browse git
class ColumnType(enum.IntEnum):
    BOOLEAN = 1
    INT8 = 2
    INT16 = 3
    INT32 = 4
    MONEY = 5
    FLOAT32 = 6
    FLOAT64 = 7
    DATETIME = 8
    BINARY = 9
    TEXT = 10
    OLE = 11
    MEMO = 12
    GUID = 15
    NUMERIC = 16
    COMPLEX = 18

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var BOOLEAN

The type of the None singleton.

var INT8

The type of the None singleton.

var INT16

The type of the None singleton.

var INT32

The type of the None singleton.

var MONEY

The type of the None singleton.

var FLOAT32

The type of the None singleton.

var FLOAT64

The type of the None singleton.

var DATETIME

The type of the None singleton.

var BINARY

The type of the None singleton.

var TEXT

The type of the None singleton.

var OLE

The type of the None singleton.

var MEMO

The type of the None singleton.

var GUID

The type of the None singleton.

var NUMERIC

The type of the None singleton.

var COMPLEX

The type of the None singleton.

class AccessDatabase (data)

Parser for Microsoft Access database files. Accepts raw bytes as input and provides a catalog mapping table names to IDs and a parse_table method that returns dict[str, list] (column name to list of row values).

Expand source code Browse git
class AccessDatabase:
    """
    Parser for Microsoft Access database files. Accepts raw bytes as input and
    provides a `catalog` mapping table names to IDs and a `parse_table` method
    that returns `dict[str, list]` (column name to list of row values).
    """

    def __init__(self, data: bytes | bytearray | memoryview):
        mv = memoryview(data)
        self._data = mv
        self._parse_header()
        self._table_defs, self._data_pages = self._categorize_pages()
        self._tables_with_data = self._link_tables_to_data()
        self.catalog: dict[str, int] = self._parse_catalog()

    def _parse_header(self):
        reader = StructReader[memoryview](self._data)
        magic = reader.read(4)
        if bytes(magic) != b'\x00\x01\x00\x00':
            raise ValueError('not a valid Access database file')
        while reader.u8():
            pass
        raw_version = reader.u32()
        try:
            version = JetVersion(raw_version)
        except ValueError:
            version = JetVersion.V3
        self._version = version
        self._is_v3 = version == JetVersion.V3
        self._page_size = 0x800 if self._is_v3 else 0x1000

    def _categorize_pages(self):
        table_defs: dict[int, memoryview] = {}
        data_pages: dict[int, memoryview] = {}
        ps = self._page_size
        data = self._data
        for offset in range(0, len(data), ps):
            page = data[offset:offset + ps]
            if len(page) < 2:
                continue
            sig = bytes(page[:2])
            if sig == _TABLE_MAGIC:
                table_defs[offset] = page
            elif sig == _DATA_MAGIC:
                data_pages[offset] = page
        return table_defs, data_pages

    def _link_tables_to_data(self) -> dict[int, _TableObj]:
        tables: dict[int, _TableObj] = {}
        ps = self._page_size
        for offset, page in self._data_pages.items():
            try:
                reader = StructReader[memoryview](page)
                owner, _ = _parse_data_page_header(reader, self._is_v3)
            except Exception:
                continue
            page_offset = owner * ps
            if page_offset not in self._table_defs:
                continue
            if page_offset not in tables:
                tables[page_offset] = _TableObj(page_offset, self._table_defs[page_offset])
            tables[page_offset].linked_pages.append(page)
        return tables

    def _parse_catalog(self) -> dict[str, int]:
        catalog_offset = 2 * self._page_size
        if catalog_offset not in self._tables_with_data:
            return {}
        catalog_table = self._tables_with_data[catalog_offset]
        parsed = self._do_parse_table(catalog_table)
        if not parsed:
            return {}
        names = parsed.get('Name', [])
        ids = parsed.get('Id', [])
        types = parsed.get('Type', [])
        flags = parsed.get('Flags', [])
        mapping: dict[str, int] = {}
        for i, name in enumerate(names):
            if not isinstance(name, str):
                continue
            if name == 'MSysObjects':
                if i < len(ids):
                    mapping[name] = ids[i]
                continue
            if i < len(types) and types[i] == 1:
                if i < len(flags) and flags[i] not in _SYSTEM_FLAGS:
                    if i < len(ids):
                        mapping[name] = ids[i]
        return mapping

    def parse_table(self, name: str) -> dict[str, list]:
        """
        Parse a table by name. Returns a dictionary mapping column names to
        lists of row values.
        """
        table_id = self.catalog.get(name)
        if table_id is None:
            return {}
        table_offset = table_id * self._page_size
        table_obj = self._tables_with_data.get(table_offset)
        if table_obj is None:
            table_def = self._table_defs.get(table_offset)
            if table_def is not None:
                table_obj = _TableObj(table_offset, table_def)
            else:
                return {}
        return self._do_parse_table(table_obj)

    def _parse_system_table(self, name: str) -> dict[str, list]:
        """
        Parse a system table by name. Access keeps system tables such as MSysAccessStorage out of
        the user-facing `catalog`, so this resolves the table id from MSysObjects when necessary.
        """
        if name in self.catalog:
            return self.parse_table(name)
        objects = self.parse_table('MSysObjects')
        names = objects.get('Name', [])
        ids = objects.get('Id', [])
        for i, candidate in enumerate(names):
            if candidate == name and i < len(ids):
                table_offset = ids[i] * self._page_size
                table_obj = self._tables_with_data.get(table_offset)
                if table_obj is None:
                    table_def = self._table_defs.get(table_offset)
                    if table_def is None:
                        return {}
                    table_obj = _TableObj(table_offset, table_def)
                return self._do_parse_table(table_obj)
        return {}

    def open_vba(self):
        """
        Reconstruct the VBA project storage tree and return it through the interface of
        `refinery.lib.ole.file.OleFile`. Microsoft Access stores VBA in one of two containers: newer
        databases explode the project into individual rows of the MSysAccessStorage system table,
        while older databases embed a complete OLE2 compound file in the MSysAccessObjects system
        table. Both are tried in turn. Returns None when no VBA project is present.
        """
        return self._open_vba_storage() or self._open_vba_objects()

    def _open_vba_storage(self):
        """
        Reconstruct the VBA project from the MSysAccessStorage system table, where each stream of
        the project is stored as an individual row and the storage hierarchy is encoded by the
        ParentId column. Returns a `refinery.lib.ole.file.VirtualOleFile` or None.
        """
        from refinery.lib.ole.file import STGTY, VirtualOleFile

        table = self._parse_system_table('MSysAccessStorage')
        ids = table.get('Id')
        if not ids:
            return None
        names = table.get('Name', [])
        parents = table.get('ParentId', [])
        types = table.get('Type', [])
        values = table.get('Lv', [])

        name_of: dict[int, str] = {}
        parent_of: dict[int, int] = {}
        for i, node_id in enumerate(ids):
            name = names[i] if i < len(names) else None
            if not isinstance(name, str):
                continue
            name_of[node_id] = name
            parent = parents[i] if i < len(parents) else None
            if isinstance(parent, int):
                parent_of[node_id] = parent

        def full_path(node_id: int) -> str | None:
            parts = [name_of[node_id]]
            seen = {node_id}
            parent = parent_of.get(node_id)
            while parent is not None and parent in name_of and parent not in seen:
                seen.add(parent)
                parent_name = name_of[parent]
                if parent_name.endswith('_ROOT') or parent_name.endswith('_SCRATCH'):
                    break
                parts.append(parent_name)
                parent = parent_of.get(parent)
            return '/'.join(reversed(parts))

        entries: list[tuple[str, int, bytes | None]] = []
        for i, node_id in enumerate(ids):
            name = name_of.get(node_id)
            if name is None or name.endswith('_ROOT') or name.endswith('_SCRATCH'):
                continue
            path = full_path(node_id)
            if not path:
                continue
            kind = types[i] if i < len(types) else None
            value = values[i] if i < len(values) else None
            if kind == 1:
                entries.append((path, STGTY.STORAGE, None))
            elif kind == 2 and isinstance(value, (bytes, bytearray, memoryview)):
                entries.append((path, STGTY.STREAM, bytes(value)))

        if not entries:
            return None
        return VirtualOleFile(entries)

    def _open_vba_objects(self):
        """
        Reconstruct the VBA project from the MSysAccessObjects system table, which stores a complete
        OLE2 compound file split across the rows of a single binary column. The rows are ordered by
        their ID column and concatenated to recover the compound file. Returns a
        `refinery.lib.ole.file.OleFile` or None.
        """
        from refinery.lib.ole.file import NotOleFileError, OleFile, OleFileError

        table = self._parse_system_table('MSysAccessObjects')
        ids = table.get('ID')
        data = table.get('Data')
        if not ids or not data:
            return None

        rows = sorted(
            zip(ids, data),
            key=lambda row: row[0] if isinstance(row[0], int) else 0,
        )
        blob = b''.join(
            bytes(value) for _, value in rows
            if isinstance(value, (bytes, bytearray, memoryview))
        )
        start = blob.find(_OLE_MAGIC)
        if start < 0:
            return None
        try:
            return OleFile(blob[start:])
        except (OleFileError, NotOleFileError, ValueError):
            return None

    def vba_references(self, vba=None) -> list[VbaReference]:
        """
        Return the type-library references declared by every VBA project in the database. Each
        `_VBA_PROJECT` stream is located through the reconstructed storage tree and its reference
        table is parsed structurally. A reconstructed storage tree may be passed in to avoid
        opening it twice; otherwise it is opened on demand.
        """
        if vba is None:
            try:
                vba = self.open_vba()
            except Exception:
                vba = None
        if vba is None:
            return []
        references: list[VbaReference] = []
        for entry in vba.listdir(streams=True):
            if entry[-1] != '_VBA_PROJECT':
                continue
            try:
                stream = bytes(vba.openstream('/'.join(entry)).read())
            except Exception:
                continue
            references.extend(_parse_vba_references(stream))
        return references

    def import_export_paths(self, vba=None) -> list[str]:
        """
        Return the file paths recorded in the import/export specifications of the database. These
        specifications are stored as XML blobs in the reconstructed storage tree and name a file on
        the machine that authored the database. A reconstructed storage tree may be passed in to
        avoid opening it twice; otherwise it is opened on demand.
        """
        if vba is None:
            try:
                vba = self.open_vba()
            except Exception:
                vba = None
        if vba is None:
            return []
        paths: dict[str, None] = {}
        for entry in vba.listdir(streams=True):
            if entry[-1] != _IMEXSPEC_STREAM:
                continue
            try:
                blob = bytes(vba.openstream('/'.join(entry)).read())
            except Exception:
                continue
            path = _parse_import_export_spec(blob)
            if path is not None:
                paths[path] = None
        return list(paths)

    def metadata(self) -> dict:
        """
        Extract triage metadata from the database. The result contains the engine name, the
        earliest creation and latest modification timestamps, the user profile paths leaked by the
        VBA project reference table (the machine that compiled the VBA project), and every file path
        recorded in the import/export specifications (the data sources on the machine that authored
        the database). Each path field is included only when its source yields at least one path.
        This method is best effort and never raises.
        """
        result: dict = {
            'Engine': _ENGINE_NAMES.get(self._version, str(self._version))
        }
        try:
            objects = self._parse_system_table('MSysObjects')
        except Exception:
            pass
        else:
            result['Created'] = min(
                (c for c in objects.get('DateCreate', []) if isinstance(c, datetime)), default=None)
            result['Updated'] = max(
                (u for u in objects.get('DateUpdate', []) if isinstance(u, datetime)), default=None)
        try:
            vba = self.open_vba()
        except Exception:
            vba = None
        else:
            if p := self._distinct(self.import_export_paths(vba)):
                result['ImportExportPaths'] = p
            if p := self._profile_paths(reference.path for reference in self.vba_references(vba)):
                result['VBAProfilePaths'] = p
        return result

    @staticmethod
    def _distinct(paths) -> list[str]:
        """
        Reduce an iterable of file paths to a sorted list of the distinct string paths.
        """
        found = {path for path in paths if isinstance(path, str)}
        return sorted(found)

    @staticmethod
    def _profile_paths(paths) -> list[str]:
        """
        Filter an iterable of file paths down to the sorted, distinct paths that lie within a user
        profile directory.
        """
        found = {path for path in paths if isinstance(path, str) and _USER_PROFILE_PATH.match(path)}
        return sorted(found)

    def _do_parse_table(self, table_obj: _TableObj) -> dict[str, list]:
        try:
            columns, variable_columns, column_count = self._get_table_columns(table_obj)
        except Exception:
            return {}
        if not columns:
            return {}

        parsed: dict[str, list] = defaultdict(list)

        if not table_obj.linked_pages:
            for col in columns.values():
                parsed[col.name] = []
            return dict(parsed)

        for data_chunk in table_obj.linked_pages:
            try:
                reader = StructReader[memoryview](data_chunk)
                _, record_offsets = _parse_data_page_header(reader, self._is_v3)
            except Exception:
                continue

            last_offset: int | None = None
            for rec_offset in record_offsets:
                if rec_offset & 0x8000:
                    last_offset = rec_offset & 0xFFF
                    continue
                if rec_offset & 0x4000:
                    ptr_offset = rec_offset & 0xFFF
                    last_offset = ptr_offset
                    if ptr_offset + 4 <= len(data_chunk):
                        overflow_ptr = struct.unpack_from('<I', data_chunk, ptr_offset)[0]
                        record = self._get_overflow_record(overflow_ptr)
                        if record:
                            self._parse_row(
                                record, columns, variable_columns, column_count, parsed)
                    continue

                if last_offset is None:
                    record = data_chunk[rec_offset:]
                else:
                    record = data_chunk[rec_offset:last_offset]
                last_offset = rec_offset
                if record:
                    self._parse_row(
                        record, columns, variable_columns, column_count, parsed)

        return dict(parsed)

    def _get_table_columns(self, table_obj: _TableObj):
        reader = StructReader[memoryview](table_obj.value)
        head = _parse_table_head(reader, self._is_v3)

        merged = table_obj.value[head.header_end:]
        if head.next_page:
            extra = self._merge_tdef_pages(head.next_page)
            merged = memoryview(bytes(merged) + bytes(extra))

        col_reader = StructReader[memoryview](merged)
        columns_list = _parse_columns(
            col_reader, head.column_count, head.real_index_count,
            head.index_count, self._is_v3)

        offset = min(c.column_index for c in columns_list) if columns_list else 0
        column_dict = {c.column_index - offset: c for c in columns_list}
        if len(column_dict) != len(columns_list):
            column_dict = {c.column_id: c for c in columns_list}

        return column_dict, head.variable_columns, head.column_count

    def _merge_tdef_pages(self, first_page_num: int) -> memoryview:
        parts = bytearray()
        page_data = self._table_defs.get(first_page_num * self._page_size)
        if page_data is None:
            return memoryview(parts)
        reader = StructReader[memoryview](page_data)
        next_page, header_end = _parse_tdef_header(reader)
        parts.extend(page_data[header_end:])
        while next_page:
            page_data = self._table_defs.get(next_page * self._page_size)
            if page_data is None:
                break
            reader = StructReader[memoryview](page_data)
            next_page, header_end = _parse_tdef_header(reader)
            parts.extend(page_data[header_end:])
        return memoryview(parts)

    def _parse_row(
        self,
        record: memoryview,
        columns: dict[int, _Column],
        variable_columns_count: int,
        column_count: int,
        parsed: dict[str, list],
    ):
        if len(record) < 1:
            return
        null_table_len = (column_count + 7) // 8
        if null_table_len >= len(record):
            return
        null_bytes = record[-null_table_len:]
        null_table = [
            ((null_bytes[i // 8]) & (1 << (i % 8))) != 0
            for i in range(null_table_len * 8)
        ]

        if not self._is_v3:
            if len(record) < 2:
                return
            row_data = record[2:]
        else:
            row_data = record[1:]

        var_columns: dict[int, _Column] = {}
        for i, column in columns.items():
            if not column.fixed_length:
                var_columns[i] = column
                continue
            col_name = column.name
            has_value = True
            if column.column_id < len(null_table):
                has_value = null_table[column.column_id]
            if column.type == ColumnType.BOOLEAN:
                parsed[col_name].append(has_value)
                continue
            if not has_value:
                parsed[col_name].append(None)
                continue
            if column.fixed_offset >= len(row_data):
                parsed[col_name].append(None)
                continue
            field_data = row_data[column.fixed_offset:]
            value = _parse_type(column.type, field_data, column.length, self._is_v3)
            parsed[col_name].append(value)

        if not var_columns:
            return

        var_columns = dict(sorted(var_columns.items()))
        reverse_record = record[::-1]
        reverse_after_null = reverse_record[null_table_len:]

        if self._is_v3:
            jump_table_count = (len(record) - 1) // 256
        else:
            jump_table_count = 0

        metadata = _parse_var_length_metadata(
            memoryview(reverse_after_null), self._is_v3, jump_table_count)

        if metadata is None:
            return

        if self._is_v3 and metadata.field_count != variable_columns_count:
            search_byte = variable_columns_count & 0xFF
            pos = bytes(reverse_after_null).find(bytes([search_byte]))
            if pos != -1 and pos < 10:
                adjusted = memoryview(reverse_after_null[pos:])
                metadata = _parse_var_length_metadata(
                    adjusted, self._is_v3, jump_table_count)
                if metadata is not None:
                    metadata = metadata._replace(
                        metadata_end=metadata.metadata_end + pos)
            else:
                return

        if not metadata or not metadata.field_offsets:
            return

        offsets = metadata.field_offsets
        jump_addition = 0
        for i, col_index in enumerate(var_columns):
            column = var_columns[col_index]
            col_name = column.name
            has_value = True
            if column.column_id < len(null_table):
                has_value = null_table[column.column_id]
            if not has_value:
                parsed[col_name].append(None)
                continue

            if self._is_v3 and i in metadata.jump_table:
                jump_addition += 0x100

            if i >= len(offsets):
                parsed[col_name].append(None)
                continue

            rel_start = offsets[i]
            if i + 1 < len(offsets):
                rel_end = offsets[i + 1]
            else:
                rel_end = metadata.var_len_count

            if rel_start == rel_end:
                parsed[col_name].append('')
                continue

            field_data = record[rel_start + jump_addition:rel_end + jump_addition]

            if column.type == ColumnType.MEMO:
                try:
                    value = self._parse_memo(field_data, raw=False)
                except Exception:
                    value = bytes(field_data)
            elif column.type == ColumnType.OLE:
                try:
                    value = self._parse_memo(field_data, raw=True)
                except Exception:
                    value = bytes(field_data)
            elif column.type == ColumnType.NUMERIC:
                if len(field_data) == 17:
                    scale = column.scale if column.scale is not None else 6
                    value = _numeric_to_string(field_data, scale)
                else:
                    value = bytes(field_data)
            else:
                value = _parse_type(column.type, field_data, len(field_data), self._is_v3)
            parsed[col_name].append(value)

    def _parse_memo(self, data: memoryview, raw: bool = False):
        if len(data) < 12:
            return bytes(data)
        memo_length = struct.unpack_from('<I', data, 0)[0]
        record_pointer = struct.unpack_from('<I', data, 4)[0]
        memo_end = 12

        if memo_length & 0x80000000:
            inline_length = memo_length & 0x3FFFFFFF
            if len(data) < memo_end + inline_length:
                memo_data = data[memo_end:]
            else:
                memo_data = data[memo_end:memo_end + inline_length]
        elif memo_length & 0x40000000:
            result = self._get_overflow_record(record_pointer)
            if result is None:
                return None
            memo_data = result
        else:
            rec_data = self._get_overflow_record(record_pointer)
            if rec_data is None:
                return None
            next_page = struct.unpack_from('<I', rec_data, 0)[0]
            parts = bytearray()
            while next_page:
                parts.extend(rec_data[4:])
                rec_data = self._get_overflow_record(next_page)
                if rec_data is None:
                    break
                next_page = struct.unpack_from('<I', rec_data, 0)[0]
            if rec_data is not None:
                parts.extend(rec_data[4:])
            memo_data = memoryview(parts)

        if not memo_data:
            return None
        if raw:
            return bytes(memo_data)
        return _parse_type(ColumnType.TEXT, memo_data, len(memo_data), self._is_v3)

    def _get_overflow_record(self, record_pointer: int) -> memoryview | None:
        record_offset = record_pointer & 0xFF
        page_num = record_pointer >> 8
        page = self._data_pages.get(page_num * self._page_size)
        if page is None:
            return None
        try:
            reader = StructReader[memoryview](page)
            _, offsets = _parse_data_page_header(reader, self._is_v3)
        except Exception:
            return None
        if record_offset >= len(offsets):
            return None
        start = offsets[record_offset]
        if start & 0x8000:
            start = start & 0xFFF
        if record_offset == 0:
            return page[start:]
        else:
            end = offsets[record_offset - 1]
            if end & 0x8000 and (end & 0xFF != 0):
                end = end & 0xFFF
            return page[start:end]

Methods

def parse_table(self, name)

Parse a table by name. Returns a dictionary mapping column names to lists of row values.

Expand source code Browse git
def parse_table(self, name: str) -> dict[str, list]:
    """
    Parse a table by name. Returns a dictionary mapping column names to
    lists of row values.
    """
    table_id = self.catalog.get(name)
    if table_id is None:
        return {}
    table_offset = table_id * self._page_size
    table_obj = self._tables_with_data.get(table_offset)
    if table_obj is None:
        table_def = self._table_defs.get(table_offset)
        if table_def is not None:
            table_obj = _TableObj(table_offset, table_def)
        else:
            return {}
    return self._do_parse_table(table_obj)
def open_vba(self)

Reconstruct the VBA project storage tree and return it through the interface of OleFile. Microsoft Access stores VBA in one of two containers: newer databases explode the project into individual rows of the MSysAccessStorage system table, while older databases embed a complete OLE2 compound file in the MSysAccessObjects system table. Both are tried in turn. Returns None when no VBA project is present.

Expand source code Browse git
def open_vba(self):
    """
    Reconstruct the VBA project storage tree and return it through the interface of
    `refinery.lib.ole.file.OleFile`. Microsoft Access stores VBA in one of two containers: newer
    databases explode the project into individual rows of the MSysAccessStorage system table,
    while older databases embed a complete OLE2 compound file in the MSysAccessObjects system
    table. Both are tried in turn. Returns None when no VBA project is present.
    """
    return self._open_vba_storage() or self._open_vba_objects()
def vba_references(self, vba=None)

Return the type-library references declared by every VBA project in the database. Each _VBA_PROJECT stream is located through the reconstructed storage tree and its reference table is parsed structurally. A reconstructed storage tree may be passed in to avoid opening it twice; otherwise it is opened on demand.

Expand source code Browse git
def vba_references(self, vba=None) -> list[VbaReference]:
    """
    Return the type-library references declared by every VBA project in the database. Each
    `_VBA_PROJECT` stream is located through the reconstructed storage tree and its reference
    table is parsed structurally. A reconstructed storage tree may be passed in to avoid
    opening it twice; otherwise it is opened on demand.
    """
    if vba is None:
        try:
            vba = self.open_vba()
        except Exception:
            vba = None
    if vba is None:
        return []
    references: list[VbaReference] = []
    for entry in vba.listdir(streams=True):
        if entry[-1] != '_VBA_PROJECT':
            continue
        try:
            stream = bytes(vba.openstream('/'.join(entry)).read())
        except Exception:
            continue
        references.extend(_parse_vba_references(stream))
    return references
def import_export_paths(self, vba=None)

Return the file paths recorded in the import/export specifications of the database. These specifications are stored as XML blobs in the reconstructed storage tree and name a file on the machine that authored the database. A reconstructed storage tree may be passed in to avoid opening it twice; otherwise it is opened on demand.

Expand source code Browse git
def import_export_paths(self, vba=None) -> list[str]:
    """
    Return the file paths recorded in the import/export specifications of the database. These
    specifications are stored as XML blobs in the reconstructed storage tree and name a file on
    the machine that authored the database. A reconstructed storage tree may be passed in to
    avoid opening it twice; otherwise it is opened on demand.
    """
    if vba is None:
        try:
            vba = self.open_vba()
        except Exception:
            vba = None
    if vba is None:
        return []
    paths: dict[str, None] = {}
    for entry in vba.listdir(streams=True):
        if entry[-1] != _IMEXSPEC_STREAM:
            continue
        try:
            blob = bytes(vba.openstream('/'.join(entry)).read())
        except Exception:
            continue
        path = _parse_import_export_spec(blob)
        if path is not None:
            paths[path] = None
    return list(paths)
def metadata(self)

Extract triage metadata from the database. The result contains the engine name, the earliest creation and latest modification timestamps, the user profile paths leaked by the VBA project reference table (the machine that compiled the VBA project), and every file path recorded in the import/export specifications (the data sources on the machine that authored the database). Each path field is included only when its source yields at least one path. This method is best effort and never raises.

Expand source code Browse git
def metadata(self) -> dict:
    """
    Extract triage metadata from the database. The result contains the engine name, the
    earliest creation and latest modification timestamps, the user profile paths leaked by the
    VBA project reference table (the machine that compiled the VBA project), and every file path
    recorded in the import/export specifications (the data sources on the machine that authored
    the database). Each path field is included only when its source yields at least one path.
    This method is best effort and never raises.
    """
    result: dict = {
        'Engine': _ENGINE_NAMES.get(self._version, str(self._version))
    }
    try:
        objects = self._parse_system_table('MSysObjects')
    except Exception:
        pass
    else:
        result['Created'] = min(
            (c for c in objects.get('DateCreate', []) if isinstance(c, datetime)), default=None)
        result['Updated'] = max(
            (u for u in objects.get('DateUpdate', []) if isinstance(u, datetime)), default=None)
    try:
        vba = self.open_vba()
    except Exception:
        vba = None
    else:
        if p := self._distinct(self.import_export_paths(vba)):
            result['ImportExportPaths'] = p
        if p := self._profile_paths(reference.path for reference in self.vba_references(vba)):
            result['VBAProfilePaths'] = p
    return result