from __future__ import annotations from dataclasses import dataclass from pathlib import Path import struct from typing import BinaryIO class Fat16Error(ValueError): pass @dataclass(frozen=True) class FatEntry: directory_offset: int first_cluster: int size: int class Fat16Image: def __init__(self, path: Path, *, writable: bool = False): self.path = Path(path) self.handle: BinaryIO = self.path.open("r+b" if writable else "rb") self.writable = writable self._load_geometry() def __enter__(self) -> "Fat16Image": return self def __exit__(self, *_args: object) -> None: self.handle.close() def _load_geometry(self) -> None: self.handle.seek(0) mbr = self.handle.read(512) if len(mbr) != 512 or mbr[510:512] != b"\x55\xaa": raise Fat16Error("image does not contain a valid MBR") entry = mbr[446:462] self.partition_lba = struct.unpack_from(" bytes: value = name.upper() if value.count(".") > 1: raise Fat16Error("FAT filename must use 8.3 form") base, dot, extension = value.partition(".") allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$~!#%&-{}()@'`" if not 1 <= len(base) <= 8 or len(extension) > 3 or any(c not in allowed for c in base + extension): raise Fat16Error(f"unsupported FAT 8.3 filename: {name}") return base.ljust(8).encode("ascii") + extension.ljust(3).encode("ascii") def _read_fat(self) -> bytearray: self.handle.seek(self.fat_offset) return bytearray(self.handle.read(self.fat_sectors * self.bytes_per_sector)) def _fat_value(self, fat: bytes, cluster: int) -> int: return struct.unpack_from(" None: struct.pack_into(" None: if not self.writable: raise Fat16Error("image was opened read-only") for index in range(self.fat_count): self.handle.seek(self.fat_offset + index * self.fat_sectors * self.bytes_per_sector) self.handle.write(fat) def find(self, name: str) -> FatEntry | None: expected = self._name83(name) self.handle.seek(self.root_offset) root = self.handle.read(self.root_bytes) for index in range(self.root_entries): offset = index * 32 raw = root[offset : offset + 32] if raw[0] == 0x00: break if raw[0] == 0xE5 or raw[11] == 0x0F: continue if raw[:11] == expected: return FatEntry( self.root_offset + offset, struct.unpack_from(" list[int]: if first_cluster < 2: return [] result: list[int] = [] seen: set[int] = set() cluster = first_cluster while 2 <= cluster < 0xFFF8: if cluster in seen or cluster > self.cluster_count + 1: raise Fat16Error("FAT cluster chain is invalid") seen.add(cluster) result.append(cluster) cluster = self._fat_value(fat, cluster) return result def _cluster_offset(self, cluster: int) -> int: return self.data_offset + (cluster - 2) * self.cluster_bytes def read_file(self, name: str) -> bytes: entry = self.find(name) if entry is None: raise FileNotFoundError(name) fat = self._read_fat() remaining = entry.size result = bytearray() for cluster in self._chain(entry.first_cluster, fat): self.handle.seek(self._cluster_offset(cluster)) chunk = self.handle.read(min(self.cluster_bytes, remaining)) result.extend(chunk) remaining -= len(chunk) if remaining == 0: break if remaining: raise Fat16Error(f"FAT file is truncated: {name}") return bytes(result) def free_bytes(self) -> int: fat = self._read_fat() free_clusters = sum( 1 for cluster in range(2, self.cluster_count + 2) if self._fat_value(fat, cluster) == 0 ) return free_clusters * self.cluster_bytes def _free_chain(self, first_cluster: int, fat: bytearray) -> None: for cluster in self._chain(first_cluster, fat): self._set_fat_value(fat, cluster, 0) def _directory_slot(self, name: str) -> int: existing = self.find(name) if existing is not None: return existing.directory_offset self.handle.seek(self.root_offset) root = self.handle.read(self.root_bytes) for index in range(self.root_entries): if root[index * 32] in {0x00, 0xE5}: return self.root_offset + index * 32 raise Fat16Error("FAT16 root directory is full") def write_file(self, name: str, data: bytes, *, contiguous: bool = False) -> None: if not self.writable: raise Fat16Error("image was opened read-only") content = bytes(data) fat = self._read_fat() existing = self.find(name) if existing is not None: self._free_chain(existing.first_cluster, fat) required = max(1, (len(content) + self.cluster_bytes - 1) // self.cluster_bytes) free = [cluster for cluster in range(2, self.cluster_count + 2) if self._fat_value(fat, cluster) == 0] if contiguous: clusters: list[int] = [] run: list[int] = [] previous = -2 for cluster in free: run = run + [cluster] if cluster == previous + 1 else [cluster] previous = cluster if len(run) == required: clusters = run break else: clusters = free[:required] if len(clusters) != required: raise Fat16Error("FAT16 partition does not have enough free clusters") for index, cluster in enumerate(clusters): next_cluster = clusters[index + 1] if index + 1 < len(clusters) else 0xFFFF self._set_fat_value(fat, cluster, next_cluster) chunk = content[index * self.cluster_bytes : (index + 1) * self.cluster_bytes] self.handle.seek(self._cluster_offset(cluster)) self.handle.write(chunk) if len(chunk) < self.cluster_bytes: self.handle.write(bytes(self.cluster_bytes - len(chunk))) self._write_fats(fat) directory_offset = self._directory_slot(name) record = bytearray(32) record[:11] = self._name83(name) record[11] = 0x20 struct.pack_into("