import dataclasses
import os
import re
import typing
from dayz_dev_tools import pbo_file_reader
from dayz_dev_tools_rust import expand
INVALID_FILENAME_RE = re.compile(b"[\t?*<>:\"|\x80-\xff]")
RESERVED_FILENAME_RE = re.compile(b"(CON|PRN|AUX|NUL|COM\\d|LPT\\d)\\.?")
def normalize_filename(parts: list[bytes]) -> str:
return os.path.sep.encode().join(parts).decode(errors="replace")
[docs]
@dataclasses.dataclass
class PBOFile:
"""Interface for accessing a file contained within a PBO archive. Instances should be obtained
using :meth:`dayz_dev_tools.pbo_reader.PBOReader.file`."""
prefix: typing.Optional[bytes]
#: The raw name of the file
filename: bytes
mime_type: bytes
original_size: int
reserved: int
#: The file's creation or modification time as a Unix timestamp
time_stamp: int
#: The size of the file in the PBO archive
data_size: int
content_reader: typing.Optional[pbo_file_reader.PBOFileReader] = None
[docs]
def unpack(self, output_file: typing.BinaryIO) -> None:
"""Write the contents of the file.
:Parameters:
- `output_file`: A binary file-like object where the contents are to be written.
"""
assert self.content_reader is not None
if self.original_size != 0 and self.original_size != self.data_size:
expanded = expand(self.content_reader.read(self.data_size - 4), self.original_size)
expected_checksum = self.content_reader.readuint()
actual_checksum = sum(expanded)
if actual_checksum != expected_checksum:
raise Exception(
f"Checksum mismatch ({actual_checksum:#x} != {expected_checksum:#x})")
output_file.write(expanded)
else:
output_file.write(self.content_reader.read(self.data_size))
[docs]
def normalized_filename(self) -> str:
"""Get the normalized version of the file's name.
The resulting filename will contain the local OS's native directory separator character and
any bytes representing illegal UTF-8 will be replaced.
:Returns:
A normalized version of the file's name.
"""
return normalize_filename(self.split_filename())
[docs]
def split_filename(self) -> list[bytes]:
"""Get the file's name as a ``list``, where each element in the list represents a component
of the file's path.
:Returns:
A list of path components.
"""
result = list(filter(lambda c: len(c) > 0, re.split(b"[\\\\/]", self.filename)))
if self.prefix is not None:
result.insert(0, self.prefix)
if len(result) == 0:
return [b""]
return result
[docs]
def unpacked_size(self) -> int:
"""Get the original size of the file. If the file is compressed, this will be different
from the :any:`PBOFile.data_size`.
:Returns:
The original size of the file.
"""
if self.original_size == 0:
return self.data_size
return self.original_size
[docs]
def type(self) -> str:
"""Get the type of the file.
:Returns:
A 4-character string representing the file type.
"""
return "".join([
c if ord(c) >= 32 and ord(c) < 127 else " "
for c in f"{self.mime_type.decode('ascii', errors='replace'):<4}"
])
[docs]
def invalid(self) -> bool:
"""Returns True if filename is not a valid Windows filename.
:Returns:
True if filename is not a valid Windows filename, or False otherwise.
"""
if INVALID_FILENAME_RE.search(self.filename) is not None:
return True
for segment in self.split_filename():
if RESERVED_FILENAME_RE.match(segment) is not None:
return True
return False
[docs]
def obfuscated(self) -> bool:
"""Returns True if filename appears to be an obfuscated script file.
:Returns:
True if filename appears to be an obfuscated script, or False otherwise.
"""
return self.invalid() and self.filename.endswith(b".c")
[docs]
def deobfuscated_split(self, index: int) -> list[bytes]:
"""Get the file's deobfuscated name as a ``list``, where each element in the list
represents a component of the file's path.
:Parameters:
- `index`: A number to uniquely identify the deobfuscated file.
:Returns:
A list of deobfuscated path components.
"""
segments = []
for segment in self.split_filename():
if INVALID_FILENAME_RE.search(segment) \
or RESERVED_FILENAME_RE.match(segment) is not None:
segments.append(f"deobfs{index:05}.c".encode())
break
segments.append(segment.rstrip(b" "))
return segments
[docs]
def deobfuscated_filename(self, index: int) -> str:
"""Get the deobfuscated version of the file's name.
:Parameters:
- `index`: A number to uniquely identify the deobfuscated file.
:Returns:
A deobfuscated version of the file's name.
"""
return normalize_filename(self.deobfuscated_split(index))