1# -*- coding: utf-8 -*-
2
3"""8DOT3 file name helper class & functions."""
4
5import errno
6import os
7import struct
8
9from pyfatfs import FAT_OEM_ENCODING, _init_check
10from pyfatfs._exceptions import PyFATException, NotAFatEntryException
11
12
13class EightDotThree:
14 """8DOT3 filename representation."""
15
16 #: Length of the byte representation in a directory entry header
17 SFN_LENGTH = 11
18
19 #: Invalid characters for 8.3 file names
20 INVALID_CHARACTERS = [range(0x0, 0x20)] + [0x22, 0x2A, 0x2B, 0x2C, 0x2E,
21 0x2F, 0x3A, 0x3B, 0x3C, 0x3D,
22 0x3E, 0x3F, 0x5B, 0x5C, 0x5D,
23 0x7C]
24
25 def __init__(self, encoding: str = FAT_OEM_ENCODING):
26 """Offer 8DOT3 filename operation.
27
28 :param encoding: Codepage for the 8.3 filename.
29 Defaults to `FAT_OEM_ENCODING` as per FAT spec.
30 """
31 self.name: bytearray = None
32 self.encoding = encoding
33 self.initialized = False
34
35 def __str__(self):
36 """Decode and un-pad SFN string."""
37 name = self.name
38 if name[0] == 0x05:
39 # Translate 0x05 to 0xE5
40 name[0] = 0xE5
41
42 base = name[:8].decode(self.encoding).rstrip()
43 ext = name[8:11].decode(self.encoding).rstrip()
44 sep = "." if len(ext) > 0 else ""
45
46 return sep.join([base, ext])
47
48 def __bytes__(self):
49 """Byte representation of the 8DOT3 name dir entry headers."""
50 return bytes(self.name)
51
52 @_init_check
53 def get_unpadded_filename(self) -> str:
54 """Retrieve the human readable filename."""
55 return str(self)
56
57 @staticmethod
58 def __raise_8dot3_nonconformant(name: str):
59 raise PyFATException(f"Given directory name "
60 f"{name} is not conform "
61 f"to 8.3 file naming convention.",
62 errno=errno.EINVAL)
63
64 def __set_name(self, name: bytes):
65 """Set self.name and verify for correctness."""
66 if len(name) != 11:
67 self.__raise_8dot3_nonconformant(name.decode(self.encoding))
68
69 self.name = name
70 self.initialized = True
71
72 def set_byte_name(self, name: bytes):
73 """Set the name as byte input from a directory entry header.
74
75 :param name: `bytes`: Padded (must be 11 bytes) 8dot3 name
76 """
77 if not isinstance(name, bytes):
78 raise TypeError(f"Given parameter must be of type bytes, "
79 f"but got {type(name)} instead.")
80
81 name = bytearray(name)
82
83 if len(name) != 11:
84 raise ValueError("Invalid byte name supplied, must be exactly "
85 "11 bytes long (8+3).")
86
87 if name[0] == 0x0 or name[0] == 0xE5:
88 # Empty directory entry
89 raise NotAFatEntryException("Given dir entry is invalid and has "
90 "no valid name.", free_type=name[0])
91
92 self.__set_name(name)
93
94 def set_str_name(self, name: str):
95 """Set the name as string from user input (i.e. folder creation)."""
96 if not isinstance(name, str):
97 raise TypeError(f"Given parameter must be of type str, "
98 f"but got {type(name)} instead.")
99
100 if not self.is_8dot3_conform(name, self.encoding):
101 self.__raise_8dot3_nonconformant(name)
102
103 name = bytearray(self._pad_8dot3_name(name).encode(self.encoding))
104 if name[0] == 0xE5:
105 name[0] = 0x05
106 self.name = name
107 self.initialized = True
108
109 @_init_check
110 def checksum(self) -> int:
111 """Calculate checksum of byte string.
112
113 :returns: Checksum as int
114 """
115 chksum = 0
116 for c in self.name:
117 chksum = ((chksum >> 1) | (chksum & 1) << 7) + c
118 chksum &= 0xFF
119 return chksum
120
121 @staticmethod
122 def __check_characters(name: str, encoding: str) -> bool:
123 """Test if given string contains invalid chars for 8.3 names.
124
125 :param name: `str`: Filename to parse
126 :raises: `ValueError` if the given string contains invalid
127 8.3 filename characters.
128 """
129 name = name.encode(encoding)
130 name = list(struct.unpack(f"{len(name)}c", name))
131 for c in name:
132 if ord(c) in EightDotThree.INVALID_CHARACTERS:
133 raise ValueError(f"Invalid characters in string '{name}', "
134 f"cannot be used as part of an 8.3 "
135 f"conform file name.")
136
137 @staticmethod
138 def is_8dot3_conform(entry_name: str, encoding: str = FAT_OEM_ENCODING):
139 """Indicate conformance of given entries name to 8.3 standard.
140
141 :param entry_name: Name of entry to check
142 :param encoding: ``str``: Encoding for SFN
143 :returns: bool indicating conformance of name to 8.3 standard
144 """
145 if entry_name != entry_name.upper():
146 # Case sensitivity check
147 return False
148
149 root, ext = os.path.splitext(entry_name)
150 ext = ext[1:]
151 if len(root) + len(ext) > 11:
152 return False
153 elif len(root) > 8 or len(ext) > 3:
154 return False
155
156 # Check for valid characters in both filename segments
157 for i in [root, ext]:
158 try:
159 EightDotThree.__check_characters(i, encoding=encoding)
160 except ValueError:
161 return False
162
163 return True
164
165 @staticmethod
166 def _pad_8dot3_name(name: str):
167 """Pad 8DOT3 name to 11 bytes for header operations.
168
169 This is required to pass the correct value to the `FATDirectoryEntry`
170 constructor as a DIR_Name.
171 """
172 root, ext = os.path.splitext(name)
173 ext = ext[1:]
174 name = root.strip().ljust(8) + ext.strip().ljust(3)
175 return name
176
177 @staticmethod
178 def make_8dot3_name(dir_name: str,
179 parent_dir_entry) -> str:
180 """Generate filename based on 8.3 rules out of a long file name.
181
182 In 8.3 notation we try to use the first 6 characters and
183 fill the rest with a tilde, followed by a number (starting
184 at 1). If that entry is already given, we increment this
185 number and try again until all possibilities are exhausted
186 (i.e. A~999999.TXT).
187
188 :param dir_name: Long name of directory entry.
189 :param parent_dir_entry: `FATDirectoryEntry`: Dir entry of parent dir.
190 :returns: `str`: 8DOT3 compliant filename.
191 :raises: PyFATException: If parent dir is not a directory
192 or all name generation possibilities
193 are exhausted
194 """
195 dirs, files, _ = parent_dir_entry.get_entries()
196 dir_entries = [e.get_short_name() for e in dirs + files]
197
198 extsep = "."
199
200 def map_chars(name: bytes) -> bytes:
201 """Map 8DOT3 valid characters.
202
203 :param name: `str`: input name
204 :returns: `str`: mapped output character
205 """
206 _name: bytes = b''
207 for b in struct.unpack(f"{len(name)}c", name):
208 if b == b' ':
209 _name += b''
210 elif ord(b) in EightDotThree.INVALID_CHARACTERS:
211 _name += b'_'
212 else:
213 _name += b
214 return _name
215
216 dir_name = dir_name.upper()
217 # Shorten to 8 chars; strip invalid characters
218 basename = os.path.splitext(dir_name)[0][0:8].strip()
219 basename = basename.encode(parent_dir_entry._encoding,
220 errors="replace")
221 basename = map_chars(basename).decode(parent_dir_entry._encoding)
222
223 # Shorten to 3 chars; strip invalid characters
224 extname = os.path.splitext(dir_name)[1][1:4].strip()
225 extname = extname.encode(parent_dir_entry._encoding,
226 errors="replace")
227 extname = map_chars(extname).decode(parent_dir_entry._encoding)
228
229 if len(extname) == 0:
230 extsep = ""
231
232 # Loop until suiting name is found
233 i = 0
234 while len(str(i)) + 1 <= 7:
235 if i > 0:
236 maxlen = 8 - (1 + len(str(i)))
237 basename = f"{basename[0:maxlen]}~{i}"
238
239 short_name = f"{basename}{extsep}{extname}"
240
241 if short_name not in dir_entries:
242 return short_name
243 i += 1
244
245 raise PyFATException("Cannot generate 8dot3 filename, "
246 "unable to find suiting short file name.",
247 errno=errno.EEXIST)