32 복합 파일 속의 MSI 데이터베이스
Microsoft 는 Windows Installer 표의 뜻은 문서로 내지만(Microsoft Learn, "Database Tables"), 데이터베이스가 복합 파일 안에 어떻게 놓이는지는 내지 않는다. 이 문서에서 배치에 관한 모든 것은 msi.dll 로 데이터베이스를 만들고, 스트림을 바이트 단위로 다시 읽고, 이 규칙으로 쓴 데이터베이스가 열리고 내보내지고 설치되는지 확인해서 알아냈다. 따로 표시하지 않으면 [observed] 다.
32.1 스트림#
표 하나가 루트 저장소 바로 아래의 스트림 하나다. 보통 패키지에는 하위 저장소가 없다.
스트림 이름#
T 라는 표는 U+4840 뒤에 T 를 UTF-16 단위 하나에 두 글자씩 압축해 붙인 이름으로 저장한다:
- 다음 64글자 알파벳의 각 글자를 0..63 으로 바꾼다:
0-9-> 0..9,A-Z-> 10..35,a-z-> 36..61,.-> 62,_-> 63. - 알파벳 글자 둘이 이어지면 단위 하나
0x3800 + first + (second << 6)가 된다. - 알파벳 글자 하나(마지막 글자, 또는 뒤에 알파벳 밖의 글자가 오는 글자)는
0x4800 + value가 된다. - 그 밖의 글자는 그대로 쓴다.
압축한 이름(표는 U+4840 접두 포함)은 CFB 이름 한도 31단위 안에 들어야 하므로, 표 이름은 알파벳 글자 60개까지 쓸 수 있다. 풀 때는 이 단계를 거꾸로 밟는다.
예: _Columns -> U+4840 U+3B3F U+43F2 U+4438 U+45B1.
다른 스트림:
| 스트림 | 이름 |
|---|---|
Binary/Icon 칸(OBJECT 열) | <Table>.<key> 를 같은 방식으로 압축하되 U+4840 접두 없이, 예 Binary.Logo(복합 키는 . 로 잇는다) |
| 안에 넣은 캐비닛 | Media.Cabinet 의 # 뒤 이름(예 cab1.cab), 압축, 접두 없음 |
| 요약 정보 | \x05SummaryInformation - 압축하지 않는다(요약 정보 참고) |
32.2 문자열 풀: _StringPool 과 _StringData#
모든 표의 모든 문자열은 공유 풀에 한 번씩만 살고, 표의 칸은 문자열 id 를 담는다.
_StringPool:
u32 header 아래 31비트: 데이터베이스 코드 페이지; 비트 31: 문자열 참조가 3바이트
그다음 문자열 id 1, 2, 3, ... 마다(id 0 은 "문자열 없음"이고 저장하지 않는다):
u16 length _StringData 속 바이트 길이
u16 refs 비트 0-14: 참조 수; 비트 15: 문자열에 0x80 이상인 바이트가 있을 때만 켜짐
- 긴 문자열(64 KiB 이상의 바이트): 항목은
length = 0,refs는 위와 같고(0 아님) 그 뒤에 전체 바이트 길이를 담은u32가 하나 더 온다. 이 둘이 여전히 id 하나를 기술한다. 본 예: 한 번 쓰인 70002바이트 문자열 ->0000 8001 00011172. length = 0이고refs = 0인 항목은 빈 id 다.- 참조 수 = 그 문자열을 담은 칸의 수, 더하기 표마다
_Tables행 몫 1, 열마다_Columns의 표 이름 몫 1 과 열 이름 몫 1. (msi.dll이 만든 모든 데이터베이스를 다시 세어 확인했다.) _StringData는 모든 문자열의 바이트를 id 순서대로 이어 붙인 것이고, 데이터베이스 코드 페이지로 되어 있다. 길이는 글자 수가 아니라 바이트 수다.- 빈 문자열은 저장하지 않는다: 칸에
""를 쓰면 null 이 저장된다. - 서로 다른 문자열이 65535개를 넘으면 머리의 비트 31 이 켜지고, 모든 표의 모든 문자열 참조가 3바이트 (아래 16비트, 그다음 위 바이트)가 된다.
코드 페이지#
데이터베이스 코드 페이지는 풀 머리의 아래 비트다: 한 번도 정하지 않았으면 0, 아니면 _ForceCodepage 가져오기로 정한 값. 65001(UTF-8)은 요즘 Windows 에서 모든 표에 통한다: 한국어, 일본어, 보조 평면 (예 U+20000, U+1F600)의 파일·폴더 이름, 레지스트리 키와 값, 바로가기 이름, 제품 이름이 모두 정확한 UTF-16 글자로 설치되고 대화창에도 보인다 - ANSI 코드 페이지가 한국어를 담지 못하는 시스템 로캘(en-US, 1252)에서도 그렇다. 그래서 따로 유니코드 설치 프로그램이 필요 없다. UTF-16 코드 페이지(1200)는 여기서 쓸 수 없다: 풀은 한 바이트 단위의 문자열을 담는다. [observed] Windows Installer 는 이름을 정규화하지 않는다: 분해형 한글(U+1100 U+1161)로 이름 붙인 폴더는 바로 그 코드 단위로 설치되고, U+AC00 으로 이름 붙인 폴더를 대신하지 않고 그 옆에 생긴다. [observed]
32.3 시스템 표#
| 표 | 열 | 키 |
|---|---|---|
_Tables | Name(문자열) | Name |
_Columns | Table(문자열), Number(i2, 1부터), Name(문자열), Type(i2) | Table, Number |
_Tables 는 자기 자신과 _Columns 를 싣지 않는다. 행이 없는 표는 스트림이 아예 없고 _Tables· _Columns 행만 있다. _Validation 은 있다면 보통 표다.
열 형식 비트(_Columns.Type)#
| 비트 | 뜻 |
|---|---|
0x00FF | 너비: 문자열 최대 길이(0 = 제한 없음), 또는 정수의 2 / 4 |
0x0100 | 유효 |
0x0200 | 지역화 가능 |
0x0400 | 비이진(글자면 켜짐, OBJECT 면 꺼짐) |
0x0800 | 문자열(글자와 OBJECT 면 켜짐) |
0x1000 | null 허용 |
0x2000 | 기본 키의 일부 |
흔한 SQL 열 형식에 msi.dll 이 쓰는 값:
| SQL | Type |
|---|---|
CHAR(72) NOT NULL(키) | 0x2D48 |
CHAR(72)(null 허용) | 0x1D48 |
CHAR(255) NOT NULL LOCALIZABLE | 0x0FFF |
LONGCHAR NOT NULL LOCALIZABLE | 0x0F00 |
SHORT NOT NULL | 0x0502 |
SHORT(null 허용) | 0x1502 |
SHORT NOT NULL(키) | 0x2502 |
LONG NOT NULL | 0x0104 |
OBJECT NOT NULL | 0x0900 |
정수 열에는 비이진 비트가 없다.
32.4 표 스트림#
- 표 스트림은 열 우선이다: 모든 행의 1열 칸, 그다음 모든 행의 2열 칸, 이런 식이다. 행 수는 스트림 크기를 행 너비로 나눈 것이다.
- 칸 너비: 문자열 참조 2바이트(긴 참조면 3), 16비트 정수 2바이트, 32비트 정수 4바이트,
OBJECT2바이트. - 정수는 부호 비트를 뒤집어 저장한다:
stored = value ^ 0x8000(16비트) 또는value ^ 0x80000000(32비트). 저장된 0 은 null 이다. 그래서 -32768 과 -2147483648 은 저장할 수 없다. OBJECT칸은 스트림이 있으면 1, null 이면 0 을 담는다.- 행은 기본 키 칸의 저장된 수 - 문자열 id, 뒤집은 정수 - 로, 키 열 순서대로 정렬한다. Windows Installer 는 이 순서에 기댄다.
문자열 id 를 문자열의 바이트 순서로 매기는 작성기는 행을 문자열 값 순으로 저절로 정렬하게 되고, 이것은 IDT 내보내기가 쓰는 순서이기도 하다.
32.5 변환(.mst)#
변환은 같은 종류의 복합 파일로, 클래스가 {000C1082-0000-0000-C000-000000000046} 이고(데이터베이스는 {000C1084-...}) 차이만 담는다: 자기 문자열 풀, 바뀐 표마다 스트림 하나, 더하거나 바꾼 이진 칸마다 스트림 하나, 요약 정보.
- 변환의 표 스트림은 행 우선이다: 레코드가 하나씩 이어지고, 저마다 16비트 마스크로 시작한다.
0은 뒤따르는 키 칸의 행을 지운다. 홀수 마스크는 행 전체를 넣는다(또는 바꾼다). 높은 바이트가 뒤따르는 칸의 수다. 그 밖의 마스크는 행을 고친다: 키 칸, 그다음 비트가 켜진 열의 칸(비트 n 이 열 n + 1). - 칸은 데이터베이스와 같이 저장한다 - 부호 비트를 뒤집은 정수, (변환 풀의) 문자열 번호, 바이트가 변환의 스트림
<Table>.<key>에 있는OBJECT표시 1. - 새 표는
_Tables넣기와 열마다_Columns넣기 하나이고, Number 는 null 이다: Windows 가 레코드 순서대로 열 번호를 매긴다. 뺀 표는_Tables지우기다. - msi.dll 은 표의 레코드를 대상 패키지의 행을 키 순서로, 그다음 지우기를 키 순서로 쓴다.
- 요약 정보: 속성 7 은 바탕 패키지의 플랫폼과 언어, 8 은 대상의 것, 9 는
{바탕 ProductCode}판;{대상 ProductCode}판;{UpgradeCode}, 14 는 필요한 Windows Installer 최소 판, 16 은 적용 전 확인(높은 워드, 예: 제품 코드와 업그레이드 코드0x0802)과 무시할 오류(낮은 워드). - 변환은 혼자서는 읽을 수 없다: 레코드에 열 형식이 없고, 그것은 바탕 패키지에서 온다.
32.6 패치(.msp)#
패치는 클래스가 {000C1086-0000-0000-C000-000000000046} 인 복합 파일이다. 뿌리는 작은 데이터베이스
MsiPatchMetadata(보이는 이름, 분류, 따로 제거할 수 있는지)와MsiPatchSequence(속한 줄기와 그 안의
자리) - 이고, 나르는 파일의 캐비닛이 스트림으로, 변환들이 하위 저장소(파일 안의 저장소, 저마다 변환의 스트림을 담는다)로 들어 있다. 요약 정보: 5 는 원본 목록, 7 은 적용 대상 제품 코드, 8 은 적용 순서대로 적은 변환 저장소(:RP1;:#RP1), 9 는 패치 코드, 15 는 필요한 Windows Installer 최소 판(4 = 3.0).
- 첫 변환은 제품을 바꾼다(달라진 행 모두). 이미 설치된 파일은 Sequence 를 지켜서, 캐시된 패키지가 자기 캐비닛에서 그것들을 계속 찾는다.
- 둘째 변환(이름에
#)이 패치로 만든다: 나르는 파일은 따로 된 범위의 Sequence 번호와 속성0x1000을 받고, 새 Media 행이 패치의 캐비닛(#<스트림>)을 가리키며,PatchPackage가 패치 코드를 그 디스크에 잇고,PatchFiles가 실행 순서에 들어간다.
32.7 IDT 보관 파일(MsiDatabaseExport 가 쓰는 것)#
기준 출력으로 쓸모 있다: 바이트까지 같은 IDT 파일을 내보내는 판독기는 Windows 와 같은 방식으로 데이터베이스를 푸는 것이다.
1줄: TAB 으로 나눈 열 이름
2줄: 열 형식: s/S(문자열), l/L(지역화 문자열), i/I(정수), v/V(이진),
대문자 = null 허용, 뒤에 너비(0 = 제한 없음; 정수는 2 또는 4)
3줄: [<코드 페이지> TAB] <표 이름> TAB <키 열> [TAB <키 열> ...]
행 : TAB 으로 나눈 칸, 줄 끝은 CR LF
- 3줄의 코드 페이지 접두는 표에 ASCII 가 아닌 문자열이 있을 때만 나온다.
- 행은 기본 키 값(문자열은 바이트, 정수는 부호 있게)으로 정렬한다 - 저장 순서가 아니다.
- null 은 빈 필드로 찍는다. 이진 칸은
<key>.ibd를 찍고, 바이트는<Table>/<key>.ibd로 간다. - 값 속의 TAB, CR, LF 는
0x10,0x11,0x19로 쓴다. [spec] _ForceCodepage.idt는 빈 줄 둘 다음에<코드 페이지> TAB _ForceCodepage다._SummaryInformation.idt는 머리 줄PropertyId TAB Value,i2 TAB l255,_SummaryInformation TAB PropertyId와 속성마다 한 행을 가진다. 날짜는 내보내는 컴퓨터의 지역 시각으로yyyy/mm/dd hh:mm:ss로 찍는다.
32.8 안전하게 읽기#
풀 길이의 합이 _StringData 크기와 정확히 같은지, 표의 모든 문자열 참조가 쓰이는 id 를 가리키는지, 표 스트림 크기가 행 너비의 배수인지, _Columns 가 표마다 열을 1..n 으로 빈틈없이 번호 매기는지 확인한다.
32.9 실제 예: 튜토리얼의 hello.msi#
튜토리얼 첫 패키지(복합 파일(Compound File Binary, CFB))의 표 File 은 이름이 U+4840 U+430F U+422F 인 스트림이다:
0x4840 (table)
F i: 0x3800 + 15 + (44 << 6) = 0x430F
l e: 0x3800 + 47 + (40 << 6) = 0x422F
_StringPool 은 e9 fd 00 00 로 시작한다: 코드 페이지 65001(UTF-8), 비트 31 은 꺼짐(2 바이트 문자열 참조). 그 뒤는 문자열 번호마다 u16 둘이다. 처음 번호들:
| 번호 | 바이트 | 길이 | 참조 | 문자열 |
|---|---|---|---|---|
| 1 | 09 00 01 00 | 9 | 1 | #cab1.cab |
| 2 | 01 00 01 00 | 1 | 1 | . |
| 3 | 01 00 01 00 | 1 | 1 | 1 |
| 4 | 05 00 03 00 | 5 | 3 | 1.0.0 |
| 5 | 07 00 01 00 | 7 | 1 | 1.2.3.4 |
| 6 | 04 00 02 00 | 4 | 2 | 1033 |
모두 170개이고, 그 바이트를 이어 붙인 것이 _StringData 의 3706 바이트다.
_Columns 가 표를 설명한다 - File 의 행들, 형식은 위의 비트 표로 풀었다:
| # | 열 | Type | 뜻 |
|---|---|---|---|
| 1 | File | 0x2D48 | key, string, non-binary, valid, width 72 |
| 2 | Component_ | 0x0D48 | string, non-binary, valid, width 72 |
| 3 | FileName | 0x0FFF | string, non-binary, localizable, valid, width 255 |
| 4 | FileSize | 0x0104 | valid, width 4 |
| 5 | Version | 0x1D48 | nullable, string, non-binary, valid, width 72 |
| 6 | Language | 0x1D14 | nullable, string, non-binary, valid, width 20 |
| 7 | Attributes | 0x1502 | nullable, non-binary, valid, width 2 |
| 8 | Sequence | 0x0104 | valid, width 4 |
File 스트림 자체는 20 바이트다: 행 하나를 열 순서로 저장했다:
3b 00 0f 00 a6 00 00 46 00 80 05 00 06 00 00 82 01 00 00 80
| 열 | 저장값 |
|---|---|
File | 3b 00 -> id 59 Hello |
Component_ | 0f 00 -> id 15 C_185f8db32271fe25f561 |
FileName | a6 00 -> id 166 hello.exe |
FileSize | 00 46 00 80 -> 0x80004600 ^ 0x80000000 = 17920 |
Version | 05 00 -> id 5 1.2.3.4 |
Language | 06 00 -> id 6 1033 |
Attributes | 00 82 -> 0x8200 ^ 0x8000 = 512 |
Sequence | 01 00 00 80 -> 0x80000001 ^ 0x80000000 = 1 |
문자열 칸은 문자열 번호이고, 정수는 맨 위 비트를 뒤집어 저장한다. 그래서 17920(0x00004600)은 0x80004600 으로 저장되고, 0 은 null 을 뜻하도록 비워 둔다.