cfbpy 0.0.2__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cfbpy-1.0.0/PKG-INFO +99 -0
- cfbpy-1.0.0/README.md +80 -0
- cfbpy-1.0.0/cfbpy/cfb.py +377 -0
- cfbpy-1.0.0/cfbpy/constants.py +24 -0
- cfbpy-1.0.0/cfbpy/directory.py +194 -0
- cfbpy-1.0.0/cfbpy/reader.py +351 -0
- cfbpy-1.0.0/pyproject.toml +41 -0
- cfbpy-1.0.0/pyproject.toml.orig +39 -0
- cfbpy-0.0.2/.gitignore +0 -11
- cfbpy-0.0.2/PKG-INFO +0 -16
- cfbpy-0.0.2/README.md +0 -2
- cfbpy-0.0.2/pyproject.toml +0 -22
- cfbpy-0.0.2/src/cfbpy/cfb.py +0 -367
- cfbpy-0.0.2/src/cfbpy/directory.py +0 -298
- {cfbpy-0.0.2 → cfbpy-1.0.0}/LICENSE +0 -0
- {cfbpy-0.0.2/src → cfbpy-1.0.0}/cfbpy/__init__.py +0 -0
cfbpy-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cfbpy
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python module for compressing/decompressing compound file binary format (CFB)
|
|
5
|
+
Author: killerwhalee
|
|
6
|
+
Author-email: killerwhalee <killerwhalee@kaist.ac.kr>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Project-URL: Homepage, https://github.com/killerwhalee/cfbpy
|
|
17
|
+
Project-URL: Issues, https://github.com/killerwhalee/cfbpy/issues
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# cfbpy
|
|
21
|
+
|
|
22
|
+
`cfbpy` is a Python package that allows you to compress and decompress compound file binary formats,
|
|
23
|
+
similar to how zip/unzip works for regular zip files.
|
|
24
|
+
It provides an easy-to-use interface for handling compound files through the CompoundFile class.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Compress a directory into a compound file format.
|
|
29
|
+
- Decompress a compound file back into a directory.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
cfbpy is written in pure Python and has no dependencies. You can install cfbpy via pip:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install cfbpy
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or add it to a [uv](https://docs.astral.sh/uv/) project:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add cfbpy
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Here’s how you can use `cfbpy` to compress and decompress files:
|
|
48
|
+
|
|
49
|
+
### Compress a Directory
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from cfbpy import compress
|
|
53
|
+
|
|
54
|
+
compress('path/to/directory', 'path/to/output.cfb')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Decompress a Compound File
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from cfbpy import decompress
|
|
61
|
+
|
|
62
|
+
decompress('path/to/input.cfb', 'path/to/extracted_directory')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The same functions are also available as `CompoundFile.compress` and `CompoundFile.decompress`.
|
|
66
|
+
|
|
67
|
+
## Methods
|
|
68
|
+
|
|
69
|
+
- `compress(src, dest=None)`: Compresses the contents of the `src` directory into a compound file at `dest` (default: `<src>.cfb`).
|
|
70
|
+
- `decompress(src, dest=None)`: Decompresses the compound file from `src` to the directory `dest` (default: `src` without its extension).
|
|
71
|
+
|
|
72
|
+
Compound file names are limited by the format, so `compress` raises `ValueError` when a file or directory name
|
|
73
|
+
|
|
74
|
+
- is longer than 31 UTF-16 code units,
|
|
75
|
+
- contains any of `/ \ : !`, or
|
|
76
|
+
- differs from a sibling only in letter case.
|
|
77
|
+
|
|
78
|
+
`decompress` raises `CompoundFileError` (a subclass of `ValueError`) if the input is not a valid compound file, and `ValueError` for entry names that would escape the destination directory, such as `..`.
|
|
79
|
+
|
|
80
|
+
Files are written as a major version 3 compound file (512-byte sectors). Both major version 3 and 4 (4096-byte sectors) files can be decompressed.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
The project is managed with [uv](https://docs.astral.sh/uv/).
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync # create .venv with the package and dev dependencies
|
|
88
|
+
uv run pytest # run tests
|
|
89
|
+
uv run python benchmarks/bench.py # measure compress/decompress time and memory
|
|
90
|
+
uv build # build sdist and wheel into dist/
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
96
|
+
|
|
97
|
+
## Contributing
|
|
98
|
+
|
|
99
|
+
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
|
cfbpy-1.0.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# cfbpy
|
|
2
|
+
|
|
3
|
+
`cfbpy` is a Python package that allows you to compress and decompress compound file binary formats,
|
|
4
|
+
similar to how zip/unzip works for regular zip files.
|
|
5
|
+
It provides an easy-to-use interface for handling compound files through the CompoundFile class.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Compress a directory into a compound file format.
|
|
10
|
+
- Decompress a compound file back into a directory.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
cfbpy is written in pure Python and has no dependencies. You can install cfbpy via pip:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install cfbpy
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or add it to a [uv](https://docs.astral.sh/uv/) project:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add cfbpy
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Here’s how you can use `cfbpy` to compress and decompress files:
|
|
29
|
+
|
|
30
|
+
### Compress a Directory
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from cfbpy import compress
|
|
34
|
+
|
|
35
|
+
compress('path/to/directory', 'path/to/output.cfb')
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Decompress a Compound File
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from cfbpy import decompress
|
|
42
|
+
|
|
43
|
+
decompress('path/to/input.cfb', 'path/to/extracted_directory')
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The same functions are also available as `CompoundFile.compress` and `CompoundFile.decompress`.
|
|
47
|
+
|
|
48
|
+
## Methods
|
|
49
|
+
|
|
50
|
+
- `compress(src, dest=None)`: Compresses the contents of the `src` directory into a compound file at `dest` (default: `<src>.cfb`).
|
|
51
|
+
- `decompress(src, dest=None)`: Decompresses the compound file from `src` to the directory `dest` (default: `src` without its extension).
|
|
52
|
+
|
|
53
|
+
Compound file names are limited by the format, so `compress` raises `ValueError` when a file or directory name
|
|
54
|
+
|
|
55
|
+
- is longer than 31 UTF-16 code units,
|
|
56
|
+
- contains any of `/ \ : !`, or
|
|
57
|
+
- differs from a sibling only in letter case.
|
|
58
|
+
|
|
59
|
+
`decompress` raises `CompoundFileError` (a subclass of `ValueError`) if the input is not a valid compound file, and `ValueError` for entry names that would escape the destination directory, such as `..`.
|
|
60
|
+
|
|
61
|
+
Files are written as a major version 3 compound file (512-byte sectors). Both major version 3 and 4 (4096-byte sectors) files can be decompressed.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
The project is managed with [uv](https://docs.astral.sh/uv/).
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv sync # create .venv with the package and dev dependencies
|
|
69
|
+
uv run pytest # run tests
|
|
70
|
+
uv run python benchmarks/bench.py # measure compress/decompress time and memory
|
|
71
|
+
uv build # build sdist and wheel into dist/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
77
|
+
|
|
78
|
+
## Contributing
|
|
79
|
+
|
|
80
|
+
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
|
cfbpy-1.0.0/cfbpy/cfb.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import array, os, secrets, shutil, sys
|
|
2
|
+
|
|
3
|
+
from . import directory, reader
|
|
4
|
+
from .constants import *
|
|
5
|
+
from .reader import CompoundFileError
|
|
6
|
+
|
|
7
|
+
SECTOR_SIZE = 1 << SECTOR_SHIFT
|
|
8
|
+
MINI_SECTOR_SIZE = 1 << MINI_SECTOR_SHIFT
|
|
9
|
+
ENTRIES_PER_SECTOR = SECTOR_SIZE // 4 # FAT/MINIFAT/DIFAT entries per sector
|
|
10
|
+
|
|
11
|
+
# Chunk size for copying large streams
|
|
12
|
+
COPY_CHUNK_SIZE = 1 << 20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pack_sectors(entries) -> bytes:
|
|
16
|
+
"""
|
|
17
|
+
Pack 32-bit sector numbers as little-endian, padded with FREESECT to a whole sector.
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
table = array.array(UINT32, entries)
|
|
22
|
+
table.extend([FREESECT] * (-len(table) % ENTRIES_PER_SECTOR))
|
|
23
|
+
|
|
24
|
+
if sys.byteorder == "big":
|
|
25
|
+
table.byteswap()
|
|
26
|
+
|
|
27
|
+
return table.tobytes()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extend_chain(table: array.array, count: int) -> int:
|
|
31
|
+
"""
|
|
32
|
+
Allocate `count` consecutive sectors at the end of `table` as one chain.
|
|
33
|
+
|
|
34
|
+
Returns first sector number of the chain, or ENDOFCHAIN if `count` is zero.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if not count:
|
|
39
|
+
return ENDOFCHAIN
|
|
40
|
+
|
|
41
|
+
start = len(table)
|
|
42
|
+
table.extend(range(start + 1, start + count))
|
|
43
|
+
table.append(ENDOFCHAIN)
|
|
44
|
+
|
|
45
|
+
return start
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _MiniStream:
|
|
49
|
+
"""
|
|
50
|
+
Mini stream and its MINIFAT, kept in memory as every stream is smaller than the cutoff size.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.data = bytearray()
|
|
56
|
+
self.fat = array.array(UINT32)
|
|
57
|
+
|
|
58
|
+
def write(self, data: bytes) -> int:
|
|
59
|
+
"""
|
|
60
|
+
Append data to the mini stream and return its first mini sector number.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
count = -(-len(data) // MINI_SECTOR_SIZE)
|
|
65
|
+
self.data += data
|
|
66
|
+
self.data += bytes(count * MINI_SECTOR_SIZE - len(data))
|
|
67
|
+
|
|
68
|
+
return _extend_chain(self.fat, count)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _SectorWriter:
|
|
72
|
+
"""
|
|
73
|
+
Write sectors sequentially to a file object while building the FAT.
|
|
74
|
+
|
|
75
|
+
Every stream is written as one run of consecutive sectors, so FAT chains are simple ranges and stream data never has to be held in memory as a whole.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, fp) -> None:
|
|
80
|
+
self.fp = fp
|
|
81
|
+
self.fat = array.array(UINT32)
|
|
82
|
+
|
|
83
|
+
# Reserve space for the header, which is written by `close()`
|
|
84
|
+
fp.write(bytes(HEADER_SIZE))
|
|
85
|
+
|
|
86
|
+
def _pad(self, size: int) -> int:
|
|
87
|
+
"""
|
|
88
|
+
Pad data written so far to a whole sector and return the number of sectors used.
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
count = -(-size // SECTOR_SIZE)
|
|
93
|
+
self.fp.write(bytes(count * SECTOR_SIZE - size))
|
|
94
|
+
|
|
95
|
+
return count
|
|
96
|
+
|
|
97
|
+
def write(self, data: bytes) -> int:
|
|
98
|
+
"""
|
|
99
|
+
Write data to new sectors and return its first sector number.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
self.fp.write(data)
|
|
104
|
+
|
|
105
|
+
return _extend_chain(self.fat, self._pad(len(data)))
|
|
106
|
+
|
|
107
|
+
def copy(self, head: bytes, src) -> tuple[int, int]:
|
|
108
|
+
"""
|
|
109
|
+
Write `head` followed by the rest of file object `src` to new sectors.
|
|
110
|
+
|
|
111
|
+
Returns first sector number and size of the stream.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
self.fp.write(head)
|
|
116
|
+
size = len(head)
|
|
117
|
+
|
|
118
|
+
while chunk := src.read(COPY_CHUNK_SIZE):
|
|
119
|
+
self.fp.write(chunk)
|
|
120
|
+
size += len(chunk)
|
|
121
|
+
|
|
122
|
+
return _extend_chain(self.fat, self._pad(size)), size
|
|
123
|
+
|
|
124
|
+
def close(self, **header) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Write FAT and DIFAT sectors, then the header.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
num_data_sectors = len(self.fat)
|
|
131
|
+
|
|
132
|
+
# FAT and DIFAT sectors need FAT entries themselves, so grow both until they fit
|
|
133
|
+
num_fat_sectors = num_difat_sectors = 0
|
|
134
|
+
while True:
|
|
135
|
+
total = num_data_sectors + num_fat_sectors + num_difat_sectors
|
|
136
|
+
fat_needed = -(-total // ENTRIES_PER_SECTOR)
|
|
137
|
+
difat_needed = -(
|
|
138
|
+
-max(0, fat_needed - NUM_HEADER_DIFAT) // (ENTRIES_PER_SECTOR - 1)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if (fat_needed, difat_needed) == (num_fat_sectors, num_difat_sectors):
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
num_fat_sectors, num_difat_sectors = fat_needed, difat_needed
|
|
145
|
+
|
|
146
|
+
first_fat_sector = num_data_sectors
|
|
147
|
+
first_difat_sector = first_fat_sector + num_fat_sectors
|
|
148
|
+
fat_sectors = list(range(first_fat_sector, first_difat_sector))
|
|
149
|
+
|
|
150
|
+
# Write FAT, marking FAT and DIFAT sectors
|
|
151
|
+
self.fat.extend([FATSECT] * num_fat_sectors)
|
|
152
|
+
self.fat.extend([DIFSECT] * num_difat_sectors)
|
|
153
|
+
self.fp.write(_pack_sectors(self.fat))
|
|
154
|
+
|
|
155
|
+
# Write DIFAT sectors for FAT sectors that do not fit in the header
|
|
156
|
+
overflow = fat_sectors[NUM_HEADER_DIFAT:]
|
|
157
|
+
per_sector = ENTRIES_PER_SECTOR - 1
|
|
158
|
+
|
|
159
|
+
for index in range(num_difat_sectors):
|
|
160
|
+
entries = overflow[index * per_sector : (index + 1) * per_sector]
|
|
161
|
+
entries.extend([FREESECT] * (per_sector - len(entries)))
|
|
162
|
+
|
|
163
|
+
if index + 1 < num_difat_sectors:
|
|
164
|
+
entries.append(first_difat_sector + index + 1)
|
|
165
|
+
else:
|
|
166
|
+
entries.append(ENDOFCHAIN)
|
|
167
|
+
|
|
168
|
+
self.fp.write(_pack_sectors(entries))
|
|
169
|
+
|
|
170
|
+
header_difat = fat_sectors[:NUM_HEADER_DIFAT]
|
|
171
|
+
header_difat.extend([FREESECT] * (NUM_HEADER_DIFAT - len(header_difat)))
|
|
172
|
+
|
|
173
|
+
# Write header
|
|
174
|
+
self.fp.seek(0)
|
|
175
|
+
self.fp.write(
|
|
176
|
+
HEADER_FORMAT.pack(
|
|
177
|
+
HEADER_SIGNATURE,
|
|
178
|
+
bytes(16), # Header CLSID
|
|
179
|
+
MINOR_VERSION,
|
|
180
|
+
MAJOR_VERSION,
|
|
181
|
+
BYTE_ORDER,
|
|
182
|
+
SECTOR_SHIFT,
|
|
183
|
+
MINI_SECTOR_SHIFT,
|
|
184
|
+
0, # Number of directory sectors, must be zero for major version 3
|
|
185
|
+
num_fat_sectors,
|
|
186
|
+
header["first_dir_sector"],
|
|
187
|
+
0, # Transaction signature number
|
|
188
|
+
MINI_STREAM_CUTOFF_SIZE,
|
|
189
|
+
header["first_mini_fat_sector"],
|
|
190
|
+
header["num_mini_fat_sectors"],
|
|
191
|
+
first_difat_sector if num_difat_sectors else ENDOFCHAIN,
|
|
192
|
+
num_difat_sectors,
|
|
193
|
+
*header_difat,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _walk(src, skip):
|
|
199
|
+
"""
|
|
200
|
+
Yield `(parent, name, path)` for every directory and file under `src`.
|
|
201
|
+
|
|
202
|
+
`parent` is the relative path of the parent directory (empty for `src` itself) and `path` is `None` for directories. Entries are sorted so output is deterministic. Files whose absolute path is in `skip` (output files inside `src`) are left out.
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
for dirpath, dirnames, filenames in os.walk(src):
|
|
207
|
+
dirnames.sort()
|
|
208
|
+
filenames.sort()
|
|
209
|
+
|
|
210
|
+
rel = os.path.relpath(dirpath, src)
|
|
211
|
+
parent = "" if rel == os.curdir else rel
|
|
212
|
+
|
|
213
|
+
if parent:
|
|
214
|
+
yield os.path.dirname(parent), os.path.basename(parent), None
|
|
215
|
+
|
|
216
|
+
for name in filenames:
|
|
217
|
+
path = os.path.join(dirpath, name)
|
|
218
|
+
|
|
219
|
+
if os.path.abspath(path) not in skip:
|
|
220
|
+
yield parent, name, path
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compress(src, dest=None):
|
|
224
|
+
"""
|
|
225
|
+
Create compound file from given directory tree.
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
src = os.path.normpath(src)
|
|
230
|
+
|
|
231
|
+
if not os.path.isdir(src):
|
|
232
|
+
raise NotADirectoryError(f"Source is not a directory: {src}")
|
|
233
|
+
|
|
234
|
+
if not dest:
|
|
235
|
+
dest = f"{src}.cfb"
|
|
236
|
+
|
|
237
|
+
dest = os.path.abspath(dest)
|
|
238
|
+
|
|
239
|
+
# Write to a temporary file next to `dest` and replace `dest` only on success, so a
|
|
240
|
+
# failure never truncates or removes an existing file
|
|
241
|
+
while True:
|
|
242
|
+
temp = os.path.join(
|
|
243
|
+
os.path.dirname(dest),
|
|
244
|
+
f".{os.path.basename(dest)}.{secrets.token_hex(4)}.tmp",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
fp = open(temp, "xb")
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
except FileExistsError:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
with fp:
|
|
256
|
+
_compress(src, fp, skip={dest, temp})
|
|
257
|
+
|
|
258
|
+
# Keep permissions of a file being replaced
|
|
259
|
+
if os.path.exists(dest):
|
|
260
|
+
shutil.copymode(dest, temp)
|
|
261
|
+
|
|
262
|
+
os.replace(temp, dest)
|
|
263
|
+
|
|
264
|
+
except BaseException:
|
|
265
|
+
os.remove(temp)
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _compress(src, fp, skip):
|
|
270
|
+
root = directory.Entry("Root Entry", obj_type=directory.OBJTY_ROOT)
|
|
271
|
+
storages = {"": root}
|
|
272
|
+
mini_stream = _MiniStream()
|
|
273
|
+
|
|
274
|
+
writer = _SectorWriter(fp)
|
|
275
|
+
|
|
276
|
+
for parent, name, path in _walk(src, skip):
|
|
277
|
+
storage = storages[parent]
|
|
278
|
+
|
|
279
|
+
# Add storage as directory entry
|
|
280
|
+
if path is None:
|
|
281
|
+
storages[os.path.join(parent, name)] = storage.add(
|
|
282
|
+
directory.Entry(name, obj_type=directory.OBJTY_STORAGE),
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
entry = storage.add(directory.Entry(name, obj_type=directory.OBJTY_STREAM))
|
|
287
|
+
|
|
288
|
+
# Write data into mini-sector if smaller than cutoff, otherwise into sector
|
|
289
|
+
with open(path, "rb") as f:
|
|
290
|
+
head = f.read(MINI_STREAM_CUTOFF_SIZE)
|
|
291
|
+
|
|
292
|
+
if len(head) < MINI_STREAM_CUTOFF_SIZE:
|
|
293
|
+
entry.sector, entry.size = mini_stream.write(head), len(head)
|
|
294
|
+
|
|
295
|
+
else:
|
|
296
|
+
entry.sector, entry.size = writer.copy(head, f)
|
|
297
|
+
|
|
298
|
+
# Write mini stream into sector; its location is stored in root entry
|
|
299
|
+
root.sector = writer.write(mini_stream.data)
|
|
300
|
+
root.size = len(mini_stream.data)
|
|
301
|
+
|
|
302
|
+
# Write mini-fat into sector
|
|
303
|
+
mini_fat_data = _pack_sectors(mini_stream.fat) if mini_stream.fat else b""
|
|
304
|
+
first_mini_fat_sector = writer.write(mini_fat_data)
|
|
305
|
+
|
|
306
|
+
# Write directory entry into sector
|
|
307
|
+
first_dir_sector = writer.write(directory.serialize(root, SECTOR_SIZE))
|
|
308
|
+
|
|
309
|
+
# Write fat, difat and header
|
|
310
|
+
writer.close(
|
|
311
|
+
first_dir_sector=first_dir_sector,
|
|
312
|
+
first_mini_fat_sector=first_mini_fat_sector,
|
|
313
|
+
num_mini_fat_sectors=len(mini_fat_data) // SECTOR_SIZE,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _safe_name(name):
|
|
318
|
+
"""
|
|
319
|
+
Return entry name as a file name, rejecting names that would escape the destination directory.
|
|
320
|
+
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
name in ("", os.curdir, os.pardir)
|
|
325
|
+
or os.sep in name
|
|
326
|
+
or (os.altsep and os.altsep in name)
|
|
327
|
+
or "\0" in name
|
|
328
|
+
or os.path.isabs(name)
|
|
329
|
+
or os.path.splitdrive(name)[0]
|
|
330
|
+
):
|
|
331
|
+
raise ValueError(f"Unsafe entry name in compound file: {name!r}")
|
|
332
|
+
|
|
333
|
+
return name
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def decompress(src, dest=None):
|
|
337
|
+
"""
|
|
338
|
+
Decompress compound file and create directory at destination.
|
|
339
|
+
|
|
340
|
+
Raises `CompoundFileError` if `src` is not a valid compound file.
|
|
341
|
+
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
# Set path same as `src` if dest is not set
|
|
345
|
+
if not dest:
|
|
346
|
+
dest, _ = os.path.splitext(src)
|
|
347
|
+
|
|
348
|
+
# Open before creating `dest`, so an invalid file leaves nothing behind
|
|
349
|
+
with reader.CompoundFileReader(src) as cfb:
|
|
350
|
+
os.makedirs(dest, exist_ok=True)
|
|
351
|
+
|
|
352
|
+
# Storages are yielded before their children
|
|
353
|
+
for parts, entry in cfb.walk():
|
|
354
|
+
path = os.path.join(dest, *parts[:-1], _safe_name(parts[-1]))
|
|
355
|
+
|
|
356
|
+
if entry.obj_type == directory.OBJTY_STORAGE:
|
|
357
|
+
os.makedirs(path, exist_ok=True)
|
|
358
|
+
|
|
359
|
+
else:
|
|
360
|
+
with open(path, "wb") as f:
|
|
361
|
+
cfb.copy_stream(entry, f)
|
|
362
|
+
|
|
363
|
+
# Return True if decompressed successfully
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class CompoundFile:
|
|
368
|
+
compress = staticmethod(compress)
|
|
369
|
+
decompress = staticmethod(decompress)
|
|
370
|
+
|
|
371
|
+
def open(self, src):
|
|
372
|
+
"""
|
|
373
|
+
Open compound file and create CompoundFile instance.
|
|
374
|
+
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import array, struct
|
|
2
|
+
|
|
3
|
+
# Header constants
|
|
4
|
+
HEADER_SIGNATURE = b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"
|
|
5
|
+
MINOR_VERSION = 0x003E
|
|
6
|
+
MAJOR_VERSION = 0x0003
|
|
7
|
+
BYTE_ORDER = 0xFFFE
|
|
8
|
+
SECTOR_SHIFT = 0x0009 # 512-byte sectors for major version 3
|
|
9
|
+
MINI_SECTOR_SHIFT = 0x0006
|
|
10
|
+
MINI_STREAM_CUTOFF_SIZE = 0x00001000
|
|
11
|
+
|
|
12
|
+
HEADER_FORMAT = struct.Struct("<8s16sHHHHH6xIIIIIIIII109I")
|
|
13
|
+
HEADER_SIZE = HEADER_FORMAT.size # 512 bytes
|
|
14
|
+
NUM_HEADER_DIFAT = 109
|
|
15
|
+
|
|
16
|
+
# Sector constants
|
|
17
|
+
MAXREGSECT = 0xFFFFFFFA #: (-6) maximum SECT
|
|
18
|
+
DIFSECT = 0xFFFFFFFC #: (-4) denotes a DIFAT sector in a FAT
|
|
19
|
+
FATSECT = 0xFFFFFFFD #: (-3) denotes a FAT sector in a FAT
|
|
20
|
+
ENDOFCHAIN = 0xFFFFFFFE #: (-2) end of a virtual stream chain
|
|
21
|
+
FREESECT = 0xFFFFFFFF #: (-1) unallocated sector
|
|
22
|
+
|
|
23
|
+
# Array typecode of unsigned 32-bit integer, used for FAT and MINIFAT
|
|
24
|
+
UINT32 = next(code for code in "IL" if array.array(code).itemsize == 4)
|