udfkit 0.1.0__py3-none-any.whl

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.
udfkit/dvd.py ADDED
@@ -0,0 +1,1086 @@
1
+ """Write a DVD-Video disc image (UDF 1.02 + ISO 9660 "bridge") in pure Python.
2
+
3
+ DVD players and libdvdread find VIDEO_TS through UDF; older systems and
4
+ `isoinfo` read the ISO 9660 side. Both file systems describe the same file
5
+ data, which is stored once. The layout follows what `genisoimage -dvd-video
6
+ -udf` writes, so images are the same size and have the same structure:
7
+
8
+ sector contents
9
+ 0-15 system area (zeros)
10
+ 16 ISO 9660 Primary Volume Descriptor
11
+ 17 ISO 9660 Volume Descriptor Set Terminator
12
+ 18-20 UDF Volume Recognition Sequence: BEA01, NSR02, TEA01
13
+ 32-47 UDF Main Volume Descriptor Sequence: Primary VD, Implementation
14
+ Use VD ("*UDF LV Info"), Partition D, Logical Volume D, Unallocated
15
+ Space D, Terminating D (16 sectors reserved, ECMA-167 3/8.4.2)
16
+ 48-63 UDF Reserve Volume Descriptor Sequence (the same, relocated)
17
+ 64-65 UDF Logical Volume Integrity Sequence: LVID, Terminating D
18
+ 256 UDF Anchor Volume Descriptor Pointer
19
+ P = 257 UDF partition start (partition block 0):
20
+ block 0, 1 File Set Descriptor, Terminating D
21
+ then each directory: File Entry + its File
22
+ Identifier Descriptors (root first, pre-order)
23
+ then one File Entry per file
24
+ ISO 9660 type L and type M path tables (each rounded up to an
25
+ even number of sectors, as genisoimage does)
26
+ ISO 9660 directories (path table order)
27
+ D file data, sector aligned, each file contiguous: VIDEO_TS first
28
+ (DVD order, see below), then AUDIO_TS, then DVD-ROM content
29
+ E end of the partition; E .. N-1 hold the Anchor Volume Descriptor
30
+ Pointer again (151 sectors, like genisoimage, which pads 150
31
+ sectors after the end anchor). UDF wants an anchor at 256 and N-1;
32
+ filling the whole tail means a reader that gets the disc size
33
+ slightly wrong still finds one.
34
+
35
+ The 150-sector tail is not required by UDF; it is genisoimage's default
36
+ `-pad` and keeps the image exactly as large as the one genisoimage writes for
37
+ the same input, so size estimates made for either tool hold for both.
38
+
39
+ VIDEO_TS file order and placement
40
+ VIDEO_TS.IFO, VIDEO_TS.VOB, VIDEO_TS.BUP, then for each title set NN:
41
+ VTS_NN_0.IFO, VTS_NN_0.VOB, VTS_NN_1.VOB .. VTS_NN_9.VOB, VTS_NN_0.BUP.
42
+ Players address title sets and their parts by sector offsets stored in the
43
+ IFO files, so when the IFOs can be read their numbers decide where each file
44
+ starts (relative to VIDEO_TS.IFO): the VMG menu VOB at vmgm_vobs, the VMG
45
+ BUP so that it ends at vmg_last_sector, each title set at the start sector
46
+ of the title search pointer table, and within a title set the menu VOB at
47
+ vtsm_vobs, the title VOBs from vtstt_vobs on and the BUP ending at
48
+ vts_last_sector. dvdauthor, for one, declares a 32-sector VMG even without
49
+ a VIDEO_TS.VOB, so there is a gap between VIDEO_TS.IFO and VIDEO_TS.BUP,
50
+ exactly as in genisoimage's image. Everything else is packed without gaps.
51
+ Files that are not IFOs (test data) are simply packed in DVD order.
52
+
53
+ File names
54
+ UDF keeps every name as it is (OSTA CS0: 8-bit when all characters are
55
+ Latin-1, otherwise 16-bit; characters beyond U+FFFF become UTF-16
56
+ surrogate pairs, which UDF 1.02 readers show as two unknown characters).
57
+ Names in VIDEO_TS are upper-cased and must be 8.3.
58
+ ISO 9660 gets level-1 names: upper case, A-Z 0-9 _, 8.3 for files (`NAME.EXT;1`,
59
+ the dot is always there), 8 characters for directories. Other names are
60
+ mapped: accents are stripped (NFKD), other characters become "_", the name
61
+ is cut to 8.3 and a clash with a name already in the directory gets a
62
+ "_N" suffix (`My long name.jpeg` -> `MY_LONG_.JPE`, then `MY_LON_1.JPE`).
63
+ Names that are already valid keep their name. Hidden entries (".name",
64
+ e.g. .DS_Store) are left out at every level.
65
+
66
+ Limits: VIDEO_TS files must not exceed 1 GiB (the DVD-Video limit); other
67
+ files must fit an ISO 9660 extent (< 4 GiB) and are stored in UDF as extents
68
+ of at most 0x3FFFF800 bytes. ISO 9660 allows 8 directory levels.
69
+
70
+ Timestamps all come from one `date` (default: now, UTC), so the same input
71
+ and date give a byte-identical image.
72
+ """
73
+
74
+ import datetime
75
+ import os
76
+ import re
77
+ import struct
78
+ import unicodedata
79
+ import zlib
80
+
81
+ from .errors import UdfError
82
+
83
+ SECTOR = 2048
84
+
85
+ # --- sector map (absolute sectors) -------------------------------------------
86
+ ISO_PVD_SECTOR = 16
87
+ ISO_TERMINATOR_SECTOR = 17
88
+ VRS_SECTOR = 18 # BEA01, NSR02, TEA01
89
+ MAIN_VDS_SECTOR = 32
90
+ RESERVE_VDS_SECTOR = 48
91
+ VDS_SECTORS = 16 # ECMA-167 3/8.4.2: at least 16 sectors
92
+ LVIS_SECTOR = 64 # Logical Volume Integrity Sequence
93
+ LVIS_SECTORS = 2
94
+ ANCHOR_SECTOR = 256 # ECMA-167 3/8.4.2.1, UDF 2.2.3
95
+ PARTITION_START = 257
96
+ TAIL_SECTORS = 151 # end anchor + genisoimage's 150-sector pad
97
+
98
+ # --- ECMA-167 descriptor tag identifiers (3/7.2.1, 4/7.2.1) -------------------
99
+ TAG_PRIMARY_VOLUME = 1
100
+ TAG_ANCHOR_POINTER = 2
101
+ TAG_IMPLEMENTATION_USE = 4
102
+ TAG_PARTITION = 5
103
+ TAG_LOGICAL_VOLUME = 6
104
+ TAG_UNALLOCATED_SPACE = 7
105
+ TAG_TERMINATING = 8
106
+ TAG_LOGICAL_VOLUME_INTEGRITY = 9
107
+ TAG_FILE_SET = 256
108
+ TAG_FILE_IDENTIFIER = 257
109
+ TAG_FILE_ENTRY = 261
110
+ DESCRIPTOR_VERSION = 2 # NSR02 (UDF 1.02)
111
+
112
+ # --- other field values ----------------------------------------------------------
113
+ UDF_REVISION = 0x0102
114
+ DOMAIN_ID = b"*OSTA UDF Compliant"
115
+ DOMAIN_FLAGS = 0x03 # hard + soft write protect (read-only disc)
116
+ LV_INFO_ID = b"*UDF LV Info"
117
+ IMPLEMENTATION_ID = b"*udfkit"
118
+ PARTITION_CONTENTS_ID = b"+NSR02"
119
+ OSTA_CHARSET = b"OSTA Compressed Unicode"
120
+ PARTITION_FLAG_ALLOCATED = 1 # ECMA-167 3/10.5.3
121
+ ACCESS_READ_ONLY = 1 # ECMA-167 3/10.5.7
122
+ INTEGRITY_CLOSE = 1 # ECMA-167 3/10.10.3
123
+ FILE_TYPE_DIRECTORY = 4 # ECMA-167 4/14.6.6
124
+ FILE_TYPE_FILE = 5
125
+ ICB_STRATEGY = 4 # ECMA-167 4/A.2 (UDF 1.02: strategy 4 only)
126
+ # ICB tag flags, ECMA-167 4/14.6.8: short_ad (0), non-relocatable, archive,
127
+ # contiguous - the same set genisoimage writes
128
+ ICB_FLAGS = 0x0010 | 0x0020 | 0x0200
129
+ FID_DIRECTORY = 0x02 # ECMA-167 4/14.4.3
130
+ FID_PARENT = 0x08
131
+ # permissions, ECMA-167 4/14.9.5: read for owner, group, other (+ execute
132
+ # = search for directories); no write on read-only media
133
+ PERM_FILE = 0x1000 | 0x0080 | 0x0004
134
+ PERM_DIRECTORY = PERM_FILE | 0x0400 | 0x0020 | 0x0001
135
+ UID_NONE = 0xFFFFFFFF # UDF 3.3.3.3: "not specified"
136
+ MAX_EXTENT = 0x3FFFF800 # < 2^30, a whole number of blocks
137
+ FIRST_UNIQUE_ID = 16 # UDF 3.2.1.1: 1..15 are reserved
138
+
139
+ GIB = 1 << 30
140
+ DVD_FILE_LIMIT = GIB
141
+ ISO_FILE_LIMIT = 0xFFFFFFFF
142
+ ISO_MAX_DEPTH = 8 # ISO 9660 6.8.2.1
143
+ LABEL_RE = re.compile(r"[A-Z0-9_]{1,32}")
144
+ _DCHARS_RE = re.compile(r"[^A-Z0-9_]")
145
+ _83_RE = re.compile(r"([A-Z0-9_]{1,8})(?:\.([A-Z0-9_]{0,3}))?")
146
+ _VMG_RE = re.compile(r"VIDEO_TS\.(IFO|VOB|BUP)")
147
+ _VTS_RE = re.compile(r"VTS_(\d\d)_(\d)\.(IFO|VOB|BUP)")
148
+ _CHUNK = 1 << 20
149
+
150
+
151
+ # ==============================================================================
152
+ # primitives
153
+ # ==============================================================================
154
+
155
+ def _crc_table():
156
+ table = []
157
+ for byte in range(256):
158
+ r = byte << 8
159
+ for _ in range(8):
160
+ r = ((r << 1) ^ 0x1021) if r & 0x8000 else (r << 1)
161
+ table.append(r & 0xFFFF)
162
+ return table
163
+
164
+
165
+ _CRC_TABLE = _crc_table()
166
+
167
+
168
+ def crc_itu_t(data):
169
+ """CRC-ITU-T (polynomial x^16 + x^12 + x^5 + 1, initial 0, no final xor),
170
+ the descriptor CRC of ECMA-167 3/7.2.6."""
171
+ crc = 0
172
+ for b in data:
173
+ crc = ((crc << 8) & 0xFFFF) ^ _CRC_TABLE[((crc >> 8) ^ b) & 0xFF]
174
+ return crc
175
+
176
+
177
+ def tag_checksum(tag):
178
+ """ECMA-167 3/7.2.3: sum of tag bytes 0-3 and 5-15, modulo 256."""
179
+ return (sum(tag[0:4]) + sum(tag[5:16])) & 0xFF
180
+
181
+
182
+ def _tagged(buf, tag_id, location):
183
+ """Fill in the 16-byte descriptor tag (ECMA-167 3/7.2) of `buf`, whose
184
+ length is the descriptor length; the CRC covers everything after the tag."""
185
+ struct.pack_into("<HHBBHHHI", buf, 0, tag_id, DESCRIPTOR_VERSION, 0, 0, 0,
186
+ crc_itu_t(buf[16:]), len(buf) - 16, location)
187
+ buf[4] = tag_checksum(buf)
188
+ return bytes(buf)
189
+
190
+
191
+ def cs0(text):
192
+ """OSTA CS0 d-characters (UDF 2.1.1): compression ID 8 + Latin-1 bytes when
193
+ every character fits, else 16 + UTF-16BE."""
194
+ if all(ord(c) < 256 for c in text):
195
+ return b"\x08" + text.encode("latin-1")
196
+ return b"\x10" + text.encode("utf-16-be", "surrogatepass")
197
+
198
+
199
+ def dstring(text, size):
200
+ """Fixed-size dstring (ECMA-167 1/7.2.12): CS0 characters, zero padding,
201
+ used length in the last byte. Cut on a character boundary if too long."""
202
+ out = bytearray(size)
203
+ if not text:
204
+ return bytes(out)
205
+ data = cs0(text)
206
+ if len(data) > size - 1:
207
+ step = 1 if data[0] == 8 else 2
208
+ data = data[:1 + (size - 2) // step * step]
209
+ out[:len(data)] = data
210
+ out[-1] = len(data)
211
+ return bytes(out)
212
+
213
+
214
+ def charspec():
215
+ """ECMA-167 1/7.2.1 charspec: type 0 (CS0) "OSTA Compressed Unicode"."""
216
+ return bytes(1) + OSTA_CHARSET.ljust(63, b"\0")
217
+
218
+
219
+ def entity_id(ident, suffix=b"", flags=0):
220
+ """ECMA-167 1/7.4 regid: flags, 23-byte identifier, 8-byte suffix."""
221
+ return bytes([flags]) + ident.ljust(23, b"\0") + suffix.ljust(8, b"\0")
222
+
223
+
224
+ def _domain_id():
225
+ return entity_id(DOMAIN_ID, struct.pack("<HB", UDF_REVISION, DOMAIN_FLAGS))
226
+
227
+
228
+ def _impl_id():
229
+ return entity_id(IMPLEMENTATION_ID)
230
+
231
+
232
+ def _local_time(date):
233
+ """(naive local time, offset in minutes) for timestamps that carry a zone.
234
+ Naive datetimes are taken as UTC; offsets that are not whole quarter hours
235
+ (ISO 9660 cannot store them) are converted to UTC."""
236
+ if date.tzinfo is None or date.utcoffset() is None:
237
+ return date, 0
238
+ seconds = date.utcoffset().total_seconds()
239
+ if seconds % 900:
240
+ return date.astimezone(datetime.timezone.utc).replace(tzinfo=None), 0
241
+ return date.replace(tzinfo=None), int(seconds // 60)
242
+
243
+
244
+ def udf_timestamp(date):
245
+ """ECMA-167 1/7.3 timestamp, type 1 (local time) with the zone offset in
246
+ minutes as a 12-bit two's complement number."""
247
+ t, offset = _local_time(date)
248
+ us = t.microsecond
249
+ return struct.pack("<HHBBBBBBBB", 0x1000 | (offset & 0x0FFF), t.year, t.month, t.day,
250
+ t.hour, t.minute, t.second, us // 10000, us // 100 % 100, us % 100)
251
+
252
+
253
+ def iso_dir_date(date):
254
+ """ISO 9660 9.1.5 recording date: 7 bytes, offset in 15-minute units."""
255
+ t, offset = _local_time(date)
256
+ return struct.pack("<BBBBBBb", t.year - 1900, t.month, t.day, t.hour, t.minute,
257
+ t.second, offset // 15)
258
+
259
+
260
+ def iso_volume_date(date):
261
+ """ISO 9660 8.4.26.1 date: 16 digits (hundredths last) + offset byte."""
262
+ t, offset = _local_time(date)
263
+ digits = "%04d%02d%02d%02d%02d%02d%02d" % (t.year, t.month, t.day, t.hour, t.minute,
264
+ t.second, t.microsecond // 10000)
265
+ return digits.encode("ascii") + struct.pack("<b", offset // 15)
266
+
267
+
268
+ _ISO_NO_DATE = b"0" * 16 + b"\0"
269
+
270
+
271
+ def _both16(v):
272
+ return struct.pack("<H", v) + struct.pack(">H", v)
273
+
274
+
275
+ def _both32(v):
276
+ return struct.pack("<I", v) + struct.pack(">I", v)
277
+
278
+
279
+ def _sectors(nbytes):
280
+ return (nbytes + SECTOR - 1) // SECTOR
281
+
282
+
283
+ # ==============================================================================
284
+ # UDF volume structures
285
+ # ==============================================================================
286
+
287
+ def volume_recognition(ident):
288
+ """ECMA-167 2/9.1 volume structure descriptor (BEA01, NSR02, TEA01)."""
289
+ return b"\0" + ident + b"\x01" + bytes(SECTOR - 7)
290
+
291
+
292
+ def anchor_pointer(location, main_vds, reserve_vds):
293
+ """ECMA-167 3/10.2 Anchor Volume Descriptor Pointer."""
294
+ buf = bytearray(512)
295
+ struct.pack_into("<IIII", buf, 16, VDS_SECTORS * SECTOR, main_vds,
296
+ VDS_SECTORS * SECTOR, reserve_vds)
297
+ return _tagged(buf, TAG_ANCHOR_POINTER, location)
298
+
299
+
300
+ def primary_volume(location, label, volume_set, date):
301
+ """ECMA-167 3/10.1 Primary Volume Descriptor (UDF 2.2.2). libdvdread reads
302
+ the disc title from its Volume Identifier."""
303
+ buf = bytearray(512)
304
+ struct.pack_into("<II", buf, 16, 0, 0) # VDS number, PVD number
305
+ buf[24:56] = dstring(label, 32)
306
+ struct.pack_into("<HHHHII", buf, 56, 1, 1, 2, 2, 1, 1) # vol seq, max, interchange
307
+ buf[72:200] = dstring(volume_set, 128) # levels 2/2, charset lists 1/1
308
+ buf[200:264] = charspec()
309
+ buf[264:328] = charspec()
310
+ buf[376:388] = udf_timestamp(date)
311
+ buf[388:420] = _impl_id()
312
+ return _tagged(buf, TAG_PRIMARY_VOLUME, location)
313
+
314
+
315
+ def implementation_use_volume(location, label):
316
+ """ECMA-167 3/10.4 + UDF 2.2.7: "*UDF LV Info" Implementation Use VD."""
317
+ buf = bytearray(512)
318
+ struct.pack_into("<I", buf, 16, 1)
319
+ buf[20:52] = entity_id(LV_INFO_ID, struct.pack("<H", UDF_REVISION))
320
+ buf[52:116] = charspec() # LVICharset
321
+ buf[116:244] = dstring(label, 128) # LogicalVolumeIdentifier
322
+ # LVInfo1..3 (3 x 36 bytes) stay empty
323
+ buf[352:384] = _impl_id()
324
+ return _tagged(buf, TAG_IMPLEMENTATION_USE, location)
325
+
326
+
327
+ def partition(location, start, length):
328
+ """ECMA-167 3/10.5 Partition Descriptor (UDF 2.2.14): partition 0,
329
+ read-only, no space bitmaps."""
330
+ buf = bytearray(512)
331
+ struct.pack_into("<IHH", buf, 16, 2, PARTITION_FLAG_ALLOCATED, 0)
332
+ buf[24:56] = entity_id(PARTITION_CONTENTS_ID)
333
+ struct.pack_into("<III", buf, 184, ACCESS_READ_ONLY, start, length)
334
+ buf[196:228] = _impl_id()
335
+ return _tagged(buf, TAG_PARTITION, location)
336
+
337
+
338
+ def logical_volume(location, label, integrity_sector):
339
+ """ECMA-167 3/10.6 Logical Volume Descriptor (UDF 2.2.4) with one type 1
340
+ partition map; the File Set Descriptor is at partition block 0."""
341
+ buf = bytearray(446)
342
+ struct.pack_into("<I", buf, 16, 3)
343
+ buf[20:84] = charspec()
344
+ buf[84:212] = dstring(label, 128)
345
+ struct.pack_into("<I", buf, 212, SECTOR) # logical block size
346
+ buf[216:248] = _domain_id()
347
+ # LogicalVolumeContentsUse: long_ad of the File Set Descriptor sequence
348
+ struct.pack_into("<IIH", buf, 248, 2 * SECTOR, 0, 0)
349
+ struct.pack_into("<II", buf, 264, 6, 1) # map table length, maps
350
+ buf[272:304] = _impl_id()
351
+ struct.pack_into("<II", buf, 432, LVIS_SECTORS * SECTOR, integrity_sector)
352
+ # type 1 partition map (3/10.7.2): type, length, volume seq 1, partition 0
353
+ struct.pack_into("<BBHH", buf, 440, 1, 6, 1, 0)
354
+ return _tagged(buf, TAG_LOGICAL_VOLUME, location)
355
+
356
+
357
+ def unallocated_space(location):
358
+ """ECMA-167 3/10.8 Unallocated Space Descriptor: no free extents."""
359
+ buf = bytearray(24)
360
+ struct.pack_into("<II", buf, 16, 4, 0)
361
+ return _tagged(buf, TAG_UNALLOCATED_SPACE, location)
362
+
363
+
364
+ def terminating(location):
365
+ """ECMA-167 3/10.9 Terminating Descriptor."""
366
+ return _tagged(bytearray(512), TAG_TERMINATING, location)
367
+
368
+
369
+ def logical_volume_integrity(location, date, next_unique_id, partition_length,
370
+ files, directories):
371
+ """ECMA-167 3/10.10 Logical Volume Integrity Descriptor (UDF 2.2.6),
372
+ closed, with the UDF implementation use area (46 bytes)."""
373
+ buf = bytearray(88 + 46)
374
+ buf[16:28] = udf_timestamp(date)
375
+ struct.pack_into("<I", buf, 28, INTEGRITY_CLOSE)
376
+ # next integrity extent (32..39) = none; contents use: next unique id
377
+ struct.pack_into("<Q", buf, 40, next_unique_id)
378
+ struct.pack_into("<IIII", buf, 72, 1, 46, 0, partition_length) # free 0, size
379
+ buf[88:120] = _impl_id()
380
+ struct.pack_into("<IIHHH", buf, 120, files, directories,
381
+ UDF_REVISION, UDF_REVISION, UDF_REVISION)
382
+ return _tagged(buf, TAG_LOGICAL_VOLUME_INTEGRITY, location)
383
+
384
+
385
+ # ==============================================================================
386
+ # UDF file structures (locations are partition block numbers)
387
+ # ==============================================================================
388
+
389
+ def file_set(block, label, root_block, date):
390
+ """ECMA-167 4/14.1 File Set Descriptor (UDF 2.3.2)."""
391
+ buf = bytearray(512)
392
+ buf[16:28] = udf_timestamp(date)
393
+ struct.pack_into("<HHIIII", buf, 28, 3, 3, 1, 1, 0, 0)
394
+ buf[48:112] = charspec()
395
+ buf[112:240] = dstring(label, 128) # logical volume identifier
396
+ buf[240:304] = charspec()
397
+ buf[304:336] = dstring(label, 32) # file set identifier
398
+ struct.pack_into("<IIH", buf, 400, SECTOR, root_block, 0) # root ICB
399
+ buf[416:448] = _domain_id()
400
+ return _tagged(buf, TAG_FILE_SET, block)
401
+
402
+
403
+ def file_identifier(block, name, characteristics, icb_block, unique_id):
404
+ """ECMA-167 4/14.4 File Identifier Descriptor, padded to 4 bytes. `name`
405
+ None is the parent entry. The ICB long_ad carries the UDF unique id
406
+ (UDF 2.3.4.3). `block` is the block where the descriptor starts."""
407
+ ident = cs0(name) if name is not None else b""
408
+ if len(ident) > 255:
409
+ raise UdfError(f"file name too long for UDF: {name!r}",
410
+ hint="UDF allows 254 Latin-1 or 127 other characters")
411
+ length = (38 + len(ident) + 3) & ~3
412
+ buf = bytearray(length)
413
+ struct.pack_into("<HBB", buf, 16, 1, characteristics, len(ident))
414
+ struct.pack_into("<IIHHIH", buf, 20, SECTOR, icb_block, 0, 0,
415
+ unique_id & 0xFFFFFFFF, 0)
416
+ buf[38:38 + len(ident)] = ident
417
+ return _tagged(buf, TAG_FILE_IDENTIFIER, block)
418
+
419
+
420
+ def file_entry(block, is_dir, size, data_block, unique_id, link_count, date):
421
+ """ECMA-167 4/14.9 File Entry with short_ad allocation descriptors (one
422
+ per extent of at most MAX_EXTENT bytes; none for an empty file)."""
423
+ ads = bytearray()
424
+ remaining, pos = size, data_block
425
+ while remaining > 0:
426
+ chunk = min(remaining, MAX_EXTENT)
427
+ ads += struct.pack("<II", chunk, pos)
428
+ pos += chunk // SECTOR
429
+ remaining -= chunk
430
+ if 176 + len(ads) > SECTOR:
431
+ raise UdfError("file too large for one UDF File Entry")
432
+ buf = bytearray(176 + len(ads))
433
+ # ICB tag (4/14.6)
434
+ struct.pack_into("<IHHHBB", buf, 16, 0, ICB_STRATEGY, 0, 1, 0,
435
+ FILE_TYPE_DIRECTORY if is_dir else FILE_TYPE_FILE)
436
+ struct.pack_into("<H", buf, 34, ICB_FLAGS)
437
+ struct.pack_into("<IIIHBBI", buf, 36, UID_NONE, UID_NONE,
438
+ PERM_DIRECTORY if is_dir else PERM_FILE, link_count, 0, 0, 0)
439
+ struct.pack_into("<QQ", buf, 56, size, _sectors(size))
440
+ stamp = udf_timestamp(date)
441
+ buf[72:84] = buf[84:96] = buf[96:108] = stamp # access, modification, attribute
442
+ struct.pack_into("<I", buf, 108, 1) # checkpoint
443
+ buf[128:160] = _impl_id()
444
+ struct.pack_into("<QII", buf, 160, unique_id, 0, len(ads))
445
+ buf[176:] = ads
446
+ return _tagged(buf, TAG_FILE_ENTRY, block)
447
+
448
+
449
+ # ==============================================================================
450
+ # ISO 9660 structures
451
+ # ==============================================================================
452
+
453
+ def iso_directory_record(ident, sector, size, is_dir, date):
454
+ """ISO 9660 9.1 directory record; `ident` is bytes (b"\\0" = ".",
455
+ b"\\1" = "..")."""
456
+ length = 33 + len(ident) + (0 if len(ident) % 2 else 1)
457
+ return (struct.pack("<BB", length, 0) + _both32(sector) + _both32(size)
458
+ + iso_dir_date(date) + bytes([2 if is_dir else 0, 0, 0]) + _both16(1)
459
+ + bytes([len(ident)]) + ident + bytes(length - 33 - len(ident)))
460
+
461
+
462
+ def iso_primary_volume(label, volume_sectors, path_table_size, l_table, m_table,
463
+ root_record, date):
464
+ """ISO 9660 8.4 Primary Volume Descriptor."""
465
+ buf = bytearray(SECTOR)
466
+ buf[0:7] = b"\x01CD001\x01"
467
+ buf[8:40] = b" " * 32 # system identifier
468
+ buf[40:72] = label.encode("ascii").ljust(32)
469
+ buf[80:88] = _both32(volume_sectors)
470
+ buf[120:124] = _both16(1) # volume set size
471
+ buf[124:128] = _both16(1) # volume sequence number
472
+ buf[128:132] = _both16(SECTOR)
473
+ buf[132:140] = _both32(path_table_size)
474
+ struct.pack_into("<II", buf, 140, l_table, 0)
475
+ struct.pack_into(">II", buf, 148, m_table, 0)
476
+ buf[156:190] = root_record
477
+ buf[190:702] = b" " * 512 # volume set .. preparer
478
+ buf[574:702] = b"UDFKIT".ljust(128) # application identifier
479
+ buf[702:813] = b" " * 111 # copyright, abstract, biblio
480
+ buf[813:830] = buf[830:847] = iso_volume_date(date)
481
+ buf[847:864] = buf[864:881] = _ISO_NO_DATE # expiration, effective
482
+ buf[881] = 1 # file structure version
483
+ return bytes(buf)
484
+
485
+
486
+ def iso_terminator():
487
+ """ISO 9660 8.3 Volume Descriptor Set Terminator."""
488
+ return b"\xffCD001\x01" + bytes(SECTOR - 7)
489
+
490
+
491
+ def path_table_record(ident, sector, parent, big_endian):
492
+ """ISO 9660 9.4 path table record."""
493
+ fmt = ">IH" if big_endian else "<IH"
494
+ rec = bytes([len(ident), 0]) + struct.pack(fmt, sector, parent) + ident
495
+ return rec + (b"\0" if len(ident) % 2 else b"")
496
+
497
+
498
+ # ==============================================================================
499
+ # the file tree
500
+ # ==============================================================================
501
+
502
+ class _Node:
503
+ __slots__ = ("name", "src", "is_dir", "size", "children", "parent", "iso_name",
504
+ "fe_block", "dir_bytes", "unique_id", "sector", "iso_sector",
505
+ "iso_size", "number", "depth")
506
+
507
+ def __init__(self, name, src, is_dir, size=0, parent=None):
508
+ self.name, self.src, self.is_dir, self.size = name, src, is_dir, size
509
+ self.parent = parent
510
+ self.children = []
511
+ self.iso_name = None
512
+ self.fe_block = self.dir_bytes = self.unique_id = None
513
+ self.sector = self.iso_sector = self.iso_size = self.number = None
514
+ self.depth = 0 if parent is None else parent.depth + 1
515
+
516
+ def path(self):
517
+ parts, n = [], self
518
+ while n.parent is not None:
519
+ parts.append(n.name)
520
+ n = n.parent
521
+ return "/" + "/".join(reversed(parts))
522
+
523
+ def walk(self):
524
+ """Pre-order: this node, then its children's subtrees in order."""
525
+ yield self
526
+ for c in self.children:
527
+ yield from c.walk()
528
+
529
+
530
+ def validate_label(label):
531
+ if not isinstance(label, str) or not LABEL_RE.fullmatch(label):
532
+ raise UdfError(
533
+ f"invalid volume label {label!r}",
534
+ hint="DVD labels are 1-32 characters from A-Z, 0-9 and _")
535
+ return label
536
+
537
+
538
+ def dvd_sort_key(name):
539
+ """Order of the VIDEO_TS files on the disc: the VMG, then each title set
540
+ (IFO, menu VOB, title VOBs 1-9, BUP); anything else last, by name."""
541
+ m = _VMG_RE.fullmatch(name)
542
+ if m:
543
+ return (0, 0, "IVB".index(m.group(1)[0]), "")
544
+ m = _VTS_RE.fullmatch(name)
545
+ if m:
546
+ vts, part, ext = int(m.group(1)), int(m.group(2)), m.group(3)
547
+ if vts >= 1 and (ext == "VOB" or part == 0):
548
+ return (1, vts, {"IFO": 0, "BUP": 11}.get(ext, 1 + part), "")
549
+ return (2, 0, 0, name)
550
+
551
+
552
+ def _scan_dir(node, path, skip, in_video_ts=False):
553
+ """Add the entries of `path` to `node`, recursively."""
554
+ try:
555
+ entries = sorted(os.scandir(path), key=lambda e: e.name)
556
+ except OSError as e:
557
+ raise UdfError(f"cannot read {path}: {e.strerror}") from e
558
+ for e in entries:
559
+ if e.name.startswith(".") or os.path.realpath(e.path) == skip:
560
+ continue
561
+ if e.is_dir():
562
+ if in_video_ts:
563
+ raise UdfError(f"VIDEO_TS must contain only files, found folder {e.name!r}")
564
+ child = _Node(e.name, e.path, True, parent=node)
565
+ if child.depth >= ISO_MAX_DEPTH:
566
+ raise UdfError(
567
+ f"{child.path()} is nested too deeply for ISO 9660",
568
+ hint=f"ISO 9660 allows {ISO_MAX_DEPTH} directory levels including the root")
569
+ _scan_dir(child, e.path, skip,
570
+ in_video_ts=node.parent is None and e.name == "VIDEO_TS")
571
+ elif e.is_file():
572
+ size = e.stat().st_size
573
+ name = e.name.upper() if in_video_ts else e.name
574
+ child = _Node(name, e.path, False, size, parent=node)
575
+ if in_video_ts:
576
+ if not _83_RE.fullmatch(name):
577
+ raise UdfError(f"VIDEO_TS/{e.name} is not an 8.3 file name",
578
+ hint="DVD-Video files are named like VTS_01_1.VOB")
579
+ if size > DVD_FILE_LIMIT:
580
+ raise UdfError(
581
+ f"VIDEO_TS/{e.name} is {size} bytes; DVD-Video files must not "
582
+ f"exceed 1 GiB ({DVD_FILE_LIMIT} bytes)")
583
+ elif size > ISO_FILE_LIMIT:
584
+ raise UdfError(f"{child.path()} is too large for ISO 9660 (4 GiB limit)")
585
+ else:
586
+ continue
587
+ node.children.append(child)
588
+ if in_video_ts:
589
+ names = [c.name for c in node.children]
590
+ dup = {n for n in names if names.count(n) > 1}
591
+ if dup:
592
+ raise UdfError(f"VIDEO_TS has names differing only in case: {sorted(dup)}")
593
+ node.children.sort(key=lambda c: dvd_sort_key(c.name))
594
+
595
+
596
+ def scan_tree(src_dir, skip=None):
597
+ """Read `src_dir` into a _Node tree: VIDEO_TS, AUDIO_TS (empty if absent),
598
+ then the other entries by name."""
599
+ video = os.path.join(src_dir, "VIDEO_TS")
600
+ if not os.path.isdir(video):
601
+ raise UdfError(f"{src_dir} has no VIDEO_TS directory")
602
+ root = _Node("", src_dir, True)
603
+ _scan_dir(root, src_dir, skip)
604
+ by_name = {c.name: c for c in root.children}
605
+ vts = by_name["VIDEO_TS"]
606
+ ats = by_name.get("AUDIO_TS")
607
+ if ats is None or not ats.is_dir:
608
+ if ats is not None:
609
+ raise UdfError(f"{src_dir}/AUDIO_TS is not a directory")
610
+ ats = _Node("AUDIO_TS", None, True, parent=root)
611
+ rest = [c for c in root.children if c.name not in ("VIDEO_TS", "AUDIO_TS")]
612
+ root.children = [vts, ats] + rest
613
+ return root
614
+
615
+
616
+ # --- ISO 9660 names --------------------------------------------------------------
617
+
618
+ def _iso_parts(name, is_dir):
619
+ """(base, ext) of the level-1 mapping of `name` (before collision fixes)."""
620
+ def clean(part):
621
+ part = unicodedata.normalize("NFKD", part).encode("ascii", "ignore").decode()
622
+ return _DCHARS_RE.sub("_", part.upper())
623
+
624
+ if is_dir:
625
+ base, ext = name, None
626
+ elif "." in name[1:]:
627
+ base, ext = name.rsplit(".", 1)
628
+ else:
629
+ base, ext = name, ""
630
+ base = clean(base)[:8] or "_"
631
+ if ext is not None:
632
+ ext = clean(ext)[:3]
633
+ return base, ext
634
+
635
+
636
+ def _iso_valid(name, is_dir):
637
+ if is_dir:
638
+ return re.fullmatch(r"[A-Z0-9_]{1,8}", name) is not None
639
+ m = _83_RE.fullmatch(name)
640
+ return m is not None and "." in name
641
+
642
+
643
+ def assign_iso_names(directory):
644
+ """Give each child of `directory` a unique ISO 9660 level-1 name. Names
645
+ that are already valid keep it; the others are mapped (see module doc)."""
646
+ taken = set()
647
+ valid = [c for c in directory.children if _iso_valid(c.name, c.is_dir)]
648
+ others = [c for c in directory.children if not _iso_valid(c.name, c.is_dir)]
649
+ for c in valid:
650
+ base, _, ext = c.name.partition(".")
651
+ c.iso_name = (base, ext if not c.is_dir else None)
652
+ taken.add(c.iso_name)
653
+ for c in others:
654
+ base, ext = _iso_parts(c.name, c.is_dir)
655
+ n = 0
656
+ while (base, ext) in taken:
657
+ n += 1
658
+ sfx = f"_{n}"
659
+ base = _iso_parts(c.name, c.is_dir)[0][:8 - len(sfx)] + sfx
660
+ c.iso_name = (base, ext)
661
+ taken.add(c.iso_name)
662
+
663
+
664
+ def _iso_ident(node):
665
+ base, ext = node.iso_name
666
+ if node.is_dir:
667
+ return base.encode("ascii")
668
+ return f"{base}.{ext};1".encode("ascii")
669
+
670
+
671
+ # ==============================================================================
672
+ # VIDEO_TS placement from the IFO files
673
+ # ==============================================================================
674
+
675
+ def _read_ifo(path, magic):
676
+ """The IFO header fields that place files (big-endian sector numbers), or
677
+ None if the file is not an IFO of that kind.
678
+
679
+ VMG (VIDEO_TS.IFO): vmg_last_sector 0x0C, vmgm_vobs 0xC0, and from the
680
+ title search pointer table (TT_SRPT, its sector at 0xC4) the start sector
681
+ of each title set. VTS (VTS_NN_0.IFO): vts_last_sector 0x0C, vtsm_vobs
682
+ 0xC0, vtstt_vobs 0xC4. All relative to the start of the IFO.
683
+ """
684
+ try:
685
+ with open(path, "rb") as f:
686
+ head = f.read(SECTOR)
687
+ if head[:12] != magic or len(head) < 0xC8:
688
+ return None
689
+ last, menu, c4 = (struct.unpack_from(">I", head, o)[0] for o in (0x0C, 0xC0, 0xC4))
690
+ if magic == b"DVDVIDEO-VTS":
691
+ return {"last_sector": last, "menu_vobs": menu, "title_vobs": c4}
692
+ # TT_SRPT: u16 title count, 6 bytes, then 12-byte entries with the
693
+ # title set number at +6 and its start sector at +8
694
+ f.seek(c4 * SECTOR)
695
+ tt = f.read(SECTOR)
696
+ starts = {}
697
+ count = struct.unpack_from(">H", tt, 0)[0] if len(tt) >= 8 else 0
698
+ for off in range(8, min(8 + 12 * count, len(tt) - 11), 12):
699
+ vts, start = tt[off + 6], struct.unpack_from(">I", tt, off + 8)[0]
700
+ if starts.setdefault(vts, start) != start:
701
+ raise UdfError(
702
+ f"VIDEO_TS.IFO gives two start sectors for title set {vts}")
703
+ return {"last_sector": last, "menu_vobs": menu, "vts_start": starts}
704
+ except OSError as e:
705
+ raise UdfError(f"cannot read {path}: {e.strerror}") from e
706
+
707
+
708
+ def video_ts_layout(video_ts):
709
+ """Sector offsets (relative to VIDEO_TS.IFO) for the files of VIDEO_TS, in
710
+ disc order. Returns ([(node, offset)], total sectors, ifo_layout_used)."""
711
+ files = {c.name: c for c in video_ts.children}
712
+ placed = []
713
+ pos = 0
714
+ used_ifo = False
715
+
716
+ def place(name, at=None, why=""):
717
+ nonlocal pos
718
+ node = files.pop(name, None)
719
+ if node is None:
720
+ return
721
+ if at is None:
722
+ at = pos
723
+ if at < pos:
724
+ raise UdfError(
725
+ f"VIDEO_TS/{name} must start at sector {at} of the DVD ({why}), "
726
+ f"but the files before it need {pos} sectors",
727
+ hint="the IFO files do not match the VOB sizes; author the disc again")
728
+ placed.append((node, at))
729
+ pos = at + _sectors(node.size)
730
+
731
+ def bup_at(last_sector, name):
732
+ """The BUP ends at the last sector of its VMG / VTS."""
733
+ node = files.get(name)
734
+ return last_sector + 1 - _sectors(node.size) if node else None
735
+
736
+ vmg = _read_ifo(files["VIDEO_TS.IFO"].src, b"DVDVIDEO-VMG") if "VIDEO_TS.IFO" in files \
737
+ else None
738
+ place("VIDEO_TS.IFO", 0)
739
+ if vmg:
740
+ used_ifo = True
741
+ place("VIDEO_TS.VOB", vmg["menu_vobs"] or None, "vmgm_vobs in VIDEO_TS.IFO")
742
+ place("VIDEO_TS.BUP", bup_at(vmg["last_sector"], "VIDEO_TS.BUP"),
743
+ "vmg_last_sector in VIDEO_TS.IFO")
744
+ else:
745
+ place("VIDEO_TS.VOB")
746
+ place("VIDEO_TS.BUP")
747
+ title_sets = sorted({dvd_sort_key(n)[1] for n in files if dvd_sort_key(n)[0] == 1})
748
+ for vts in title_sets:
749
+ ifo_name = f"VTS_{vts:02d}_0.IFO"
750
+ start = vmg["vts_start"].get(vts) if vmg else None
751
+ if ifo_name not in files:
752
+ raise UdfError(f"VIDEO_TS has files of title set {vts} but no {ifo_name}")
753
+ place(ifo_name, start, f"title set {vts} start sector in VIDEO_TS.IFO")
754
+ base = placed[-1][1]
755
+ info = _read_ifo(placed[-1][0].src, b"DVDVIDEO-VTS")
756
+ if info:
757
+ used_ifo = True
758
+ menu = info["menu_vobs"]
759
+ place(f"VTS_{vts:02d}_0.VOB", base + menu if menu else None,
760
+ f"vtsm_vobs in {ifo_name}")
761
+ title = info["title_vobs"]
762
+ for part in range(1, 10):
763
+ at = base + title if part == 1 and title else None
764
+ place(f"VTS_{vts:02d}_{part}.VOB", at, f"vtstt_vobs in {ifo_name}")
765
+ bup = f"VTS_{vts:02d}_0.BUP"
766
+ last = bup_at(info["last_sector"], bup)
767
+ place(bup, base + last if last is not None else None,
768
+ f"vts_last_sector in {ifo_name}")
769
+ else:
770
+ for part in range(0, 10):
771
+ place(f"VTS_{vts:02d}_{part}.VOB")
772
+ place(f"VTS_{vts:02d}_0.BUP")
773
+ for node in video_ts.children: # anything else, in sort order
774
+ if node.name in files:
775
+ place(node.name)
776
+ return placed, pos, used_ifo
777
+
778
+
779
+ # ==============================================================================
780
+ # the writer
781
+ # ==============================================================================
782
+
783
+ def write_image(src_dir, out_path, label, *, date=None, on_progress=None, shift=0):
784
+ """Write a UDF 1.02 + ISO 9660 DVD-Video image of `src_dir` to `out_path`.
785
+
786
+ `src_dir` holds VIDEO_TS (required), AUDIO_TS (created empty in the image
787
+ if missing) and optionally DVD-ROM files and folders. `date` (a datetime)
788
+ stamps every structure; it defaults to now (UTC). `on_progress(fraction)`
789
+ is called as data is written. `shift` empty sectors go before VIDEO_TS
790
+ (to put a dual-layer disc's layer break on an ECC block). Returns
791
+ statistics: sectors, bytes, files, directories, the UDF volume name,
792
+ whether the IFOs placed the VIDEO_TS files, and {"/path": (first sector,
793
+ bytes)} for every file.
794
+ """
795
+ validate_label(label)
796
+ if date is None:
797
+ date = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
798
+ if not 1970 <= date.year <= 2155:
799
+ raise UdfError(f"date {date} is out of range for ISO 9660 (1970-2155)")
800
+ out_path = os.path.abspath(out_path)
801
+ root = scan_tree(src_dir, skip=os.path.realpath(out_path))
802
+ layout = _plan(root, date, shift)
803
+ try:
804
+ _write(root, layout, out_path, label, date, on_progress)
805
+ except BaseException:
806
+ try:
807
+ os.remove(out_path)
808
+ except OSError:
809
+ pass
810
+ raise
811
+ files = [n for n in root.walk() if not n.is_dir]
812
+ return {
813
+ "path": out_path,
814
+ "sectors": layout["total"],
815
+ "bytes": layout["total"] * SECTOR,
816
+ "files": len(files),
817
+ "directories": sum(1 for n in root.walk() if n.is_dir),
818
+ "label": label,
819
+ "udf_volume": dstring_text(dstring(label, 32)),
820
+ "ifo_layout": layout["ifo_layout"],
821
+ "partition_start": PARTITION_START,
822
+ "data_start": layout["data_start"],
823
+ "extents": {n.path(): (n.sector, n.size) for n in files},
824
+ "iso_names": {n.path(): _iso_path(n) for n in root.walk() if n.parent is not None},
825
+ }
826
+
827
+
828
+ def plan_image(src_dir):
829
+ """Where an image of `src_dir` would put VIDEO_TS: {"video_ts": sector of
830
+ VIDEO_TS.IFO, "total": sectors} (nothing is written)."""
831
+ root = scan_tree(src_dir)
832
+ layout = _plan(root, datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc))
833
+ return {"video_ts": layout["data_start"], "total": layout["total"]}
834
+
835
+
836
+ def dstring_text(raw):
837
+ """Decode a dstring back to text (for reporting)."""
838
+ length = raw[-1]
839
+ if not length:
840
+ return ""
841
+ body = raw[1:length]
842
+ return body.decode("latin-1") if raw[0] == 8 else body.decode("utf-16-be", "replace")
843
+
844
+
845
+ def _iso_path(node):
846
+ parts = []
847
+ while node.parent is not None:
848
+ parts.append(_iso_ident(node).decode("ascii"))
849
+ node = node.parent
850
+ return "/" + "/".join(reversed(parts))
851
+
852
+
853
+ def _plan(root, date, shift=0):
854
+ """Assign every structure its place. Returns a dict with the sector map."""
855
+ dirs = [n for n in root.walk() if n.is_dir]
856
+ files = [n for n in root.walk() if not n.is_dir]
857
+
858
+ # UDF directories: File Entry followed by the File Identifier Descriptors
859
+ block = 2 # after the FSD and its terminator
860
+ uid = FIRST_UNIQUE_ID
861
+ for d in dirs:
862
+ if d is root:
863
+ d.unique_id = 0 # UDF 3.2.1.1: the root has 0
864
+ else:
865
+ d.unique_id, uid = uid, uid + 1
866
+ d.dir_bytes = len(file_identifier(0, None, 0, 0, 0)) + sum(
867
+ len(file_identifier(0, c.name, 0, 0, 0)) for c in d.children)
868
+ d.fe_block = block
869
+ block += 1 + _sectors(d.dir_bytes)
870
+ for f in files:
871
+ f.unique_id = uid
872
+ uid += 1
873
+ f.fe_block = block
874
+ block += 1
875
+ sector = PARTITION_START + block
876
+
877
+ # ISO 9660: names, path table (level, parent number, name), directories
878
+ for d in dirs:
879
+ assign_iso_names(d)
880
+ # breadth first, each parent's subdirectories by name: ISO 9660 6.9.1
881
+ # orders the path table by level, parent number, then name
882
+ ordered = [root]
883
+ i = 0
884
+ while i < len(ordered):
885
+ d = ordered[i]
886
+ d.number = i + 1
887
+ ordered.extend(sorted((c for c in d.children if c.is_dir), key=lambda c: c.iso_name[0]))
888
+ i += 1
889
+ path_table_size = sum(len(path_table_record(_pt_ident(d), 0, 0, False)) for d in ordered)
890
+ path_blocks = _sectors(path_table_size)
891
+ path_blocks += path_blocks & 1 # even, as genisoimage
892
+ l_table, m_table = sector, sector + path_blocks
893
+ sector += 2 * path_blocks
894
+ for d in ordered:
895
+ d.iso_size = _iso_dir_size(d, date)
896
+ d.iso_sector = sector
897
+ sector += d.iso_size // SECTOR
898
+
899
+ # file data: VIDEO_TS by the IFOs, then everything else in tree order
900
+ data_start = sector
901
+ video_ts = root.children[0]
902
+ placed, vts_len, ifo_layout = video_ts_layout(video_ts)
903
+ data = [(n, data_start + shift + off) for n, off in placed]
904
+ sector = data_start + shift + vts_len
905
+ for f in files:
906
+ if f.parent is video_ts:
907
+ continue
908
+ data.append((f, sector))
909
+ sector += _sectors(f.size)
910
+ for n, s in data:
911
+ n.sector = s
912
+ end = sector
913
+ return {"dirs": dirs, "files": files, "ordered": ordered, "data": data,
914
+ "path_table_size": path_table_size, "l_table": l_table, "m_table": m_table,
915
+ "data_start": data_start, "end": end, "total": end + TAIL_SECTORS,
916
+ "next_unique_id": uid, "ifo_layout": ifo_layout}
917
+
918
+
919
+ def _pt_ident(d):
920
+ return b"\0" if d.parent is None else _iso_ident(d)
921
+
922
+
923
+ def _iso_children(d):
924
+ """Directory records in ISO 9660 9.3 order: by name, then extension."""
925
+ return sorted(d.children, key=lambda c: (c.iso_name[0], c.iso_name[1] or ""))
926
+
927
+
928
+ def _iso_records(d, date):
929
+ """The records of directory `d` (sizes/sectors filled in when known)."""
930
+ parent = d.parent or d
931
+ recs = [iso_directory_record(b"\0", d.iso_sector or 0, d.iso_size or 0, True, date),
932
+ iso_directory_record(b"\1", parent.iso_sector or 0, parent.iso_size or 0, True,
933
+ date)]
934
+ for c in _iso_children(d):
935
+ if c.is_dir:
936
+ recs.append(iso_directory_record(_iso_ident(c), c.iso_sector or 0,
937
+ c.iso_size or 0, True, date))
938
+ else:
939
+ recs.append(iso_directory_record(_iso_ident(c), c.sector or 0, c.size, False,
940
+ date))
941
+ return recs
942
+
943
+
944
+ def _pack_records(recs):
945
+ """Lay records out in sectors; a record never crosses a sector boundary."""
946
+ out = bytearray()
947
+ for r in recs:
948
+ room = SECTOR - len(out) % SECTOR
949
+ if len(r) > room:
950
+ out += bytes(room)
951
+ out += r
952
+ out += bytes(-len(out) % SECTOR)
953
+ return bytes(out)
954
+
955
+
956
+ def _iso_dir_size(d, date):
957
+ return len(_pack_records(_iso_records(d, date)))
958
+
959
+
960
+ def _write(root, layout, out_path, label, date, on_progress):
961
+ total = layout["total"]
962
+ data_start = layout["data_start"]
963
+ meta = bytearray(data_start * SECTOR)
964
+
965
+ def put(sector, data):
966
+ meta[sector * SECTOR:sector * SECTOR + len(data)] = data
967
+
968
+ p_start = PARTITION_START
969
+ p_length = layout["end"] - p_start
970
+
971
+ # ISO 9660 volume descriptors
972
+ root_record = iso_directory_record(b"\0", root.iso_sector, root.iso_size, True, date)
973
+ put(ISO_PVD_SECTOR, iso_primary_volume(label, total, layout["path_table_size"],
974
+ layout["l_table"], layout["m_table"],
975
+ root_record, date))
976
+ put(ISO_TERMINATOR_SECTOR, iso_terminator())
977
+ for i, ident in enumerate((b"BEA01", b"NSR02", b"TEA01")):
978
+ put(VRS_SECTOR + i, volume_recognition(ident))
979
+
980
+ # UDF volume descriptor sequences (main and reserve), integrity, anchor
981
+ # UDF 2.2.2.5: the volume set identifier starts with 16 "unique" hex
982
+ # digits; derive them from the date and label so output is reproducible
983
+ utc = date if date.tzinfo else date.replace(tzinfo=datetime.timezone.utc)
984
+ volume_set = "%08X%08X" % (int(utc.timestamp()) & 0xFFFFFFFF,
985
+ zlib.crc32(label.encode("ascii")))
986
+ for base in (MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR):
987
+ put(base + 0, primary_volume(base + 0, label, volume_set, date))
988
+ put(base + 1, implementation_use_volume(base + 1, label))
989
+ put(base + 2, partition(base + 2, p_start, p_length))
990
+ put(base + 3, logical_volume(base + 3, label, LVIS_SECTOR))
991
+ put(base + 4, unallocated_space(base + 4))
992
+ put(base + 5, terminating(base + 5))
993
+ put(LVIS_SECTOR, logical_volume_integrity(
994
+ LVIS_SECTOR, date, layout["next_unique_id"], p_length,
995
+ len(layout["files"]), len(layout["dirs"])))
996
+ put(LVIS_SECTOR + 1, terminating(LVIS_SECTOR + 1))
997
+ put(ANCHOR_SECTOR, anchor_pointer(ANCHOR_SECTOR, MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR))
998
+
999
+ # UDF partition: file set, directories, file entries
1000
+ put(p_start + 0, file_set(0, label, root.fe_block, date))
1001
+ put(p_start + 1, terminating(1))
1002
+ for d in layout["dirs"]:
1003
+ links = 1 + sum(1 for c in d.children if c.is_dir)
1004
+ put(p_start + d.fe_block, file_entry(d.fe_block, True, d.dir_bytes, d.fe_block + 1,
1005
+ d.unique_id, links, date))
1006
+ first = d.fe_block + 1
1007
+ parent = d.parent or d
1008
+ fids = bytearray(file_identifier(first, None, FID_DIRECTORY | FID_PARENT,
1009
+ parent.fe_block, parent.unique_id))
1010
+ for c in d.children:
1011
+ fids += file_identifier(first + len(fids) // SECTOR, c.name,
1012
+ FID_DIRECTORY if c.is_dir else 0, c.fe_block, c.unique_id)
1013
+ put(p_start + first, fids)
1014
+ for f in layout["files"]:
1015
+ data_block = f.sector - p_start if f.size else 0
1016
+ put(p_start + f.fe_block, file_entry(f.fe_block, False, f.size, data_block,
1017
+ f.unique_id, 1, date))
1018
+
1019
+ # ISO 9660 path tables and directories
1020
+ for big, sector in ((False, layout["l_table"]), (True, layout["m_table"])):
1021
+ table = b"".join(path_table_record(_pt_ident(d), d.iso_sector,
1022
+ (d.parent or d).number, big)
1023
+ for d in layout["ordered"])
1024
+ put(sector, table)
1025
+ for d in layout["ordered"]:
1026
+ put(d.iso_sector, _pack_records(_iso_records(d, date)))
1027
+
1028
+ # stream everything to the file
1029
+ done = 0
1030
+ total_bytes = total * SECTOR
1031
+
1032
+ def progress(n):
1033
+ nonlocal done
1034
+ done += n
1035
+ if on_progress:
1036
+ on_progress(min(1.0, done / total_bytes))
1037
+
1038
+ if on_progress:
1039
+ on_progress(0.0)
1040
+ zeros = bytes(_CHUNK)
1041
+ with open(out_path, "wb") as out:
1042
+ out.write(meta)
1043
+ progress(len(meta))
1044
+ pos = data_start
1045
+ for node, sector in layout["data"]:
1046
+ _write_zeros(out, (sector - pos) * SECTOR, zeros)
1047
+ progress((sector - pos) * SECTOR)
1048
+ _copy_file(node, out, progress)
1049
+ pad = -node.size % SECTOR
1050
+ out.write(zeros[:pad])
1051
+ pos = sector + _sectors(node.size)
1052
+ _write_zeros(out, (layout["end"] - pos) * SECTOR, zeros)
1053
+ progress((layout["end"] - pos) * SECTOR)
1054
+ for s in range(layout["end"], total):
1055
+ out.write(anchor_pointer(s, MAIN_VDS_SECTOR, RESERVE_VDS_SECTOR).ljust(SECTOR, b"\0"))
1056
+ progress((total - layout["end"]) * SECTOR)
1057
+ if out.tell() != total_bytes:
1058
+ raise UdfError(f"internal error: wrote {out.tell()} bytes, "
1059
+ f"expected {total_bytes}")
1060
+
1061
+
1062
+ def _write_zeros(out, n, zeros):
1063
+ while n > 0:
1064
+ k = min(n, len(zeros))
1065
+ out.write(zeros[:k])
1066
+ n -= k
1067
+
1068
+
1069
+ def _copy_file(node, out, progress):
1070
+ """Copy a file's bytes, checking it did not change size since the scan."""
1071
+ remaining = node.size
1072
+ try:
1073
+ with open(node.src, "rb") as f:
1074
+ while remaining > 0:
1075
+ chunk = f.read(min(_CHUNK, remaining))
1076
+ if not chunk:
1077
+ break
1078
+ out.write(chunk)
1079
+ remaining -= len(chunk)
1080
+ progress(len(chunk))
1081
+ extra = f.read(1)
1082
+ except OSError as e:
1083
+ raise UdfError(f"cannot read {node.src}: {e.strerror}") from e
1084
+ if remaining or extra:
1085
+ raise UdfError(f"{node.src} changed size while the image was written")
1086
+ progress(-node.size % SECTOR)