Subversion Repositories Kolibri OS

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
9357 Boppan 1
#!/usr/bin/env python3
2
#
3
# makeflop.py
4
# Version 3
5
# Brad Smith, 2019
6
# http://rainwarrior.ca
7
#
8
# Simple file operations for a FAT12 floppy disk image.
9
# Public domain.
10
 
11
import sys
12
assert sys.version_info[0] == 3, "Python 3 required."
13
 
14
import struct
15
import datetime
16
import os
17
 
18
class Floppy:
19
    """
20
    Simple file operations for a FAT12 floppy disk image.
21
 
22
    Floppy() - creates a blank DOS formatted 1.44MB disk.
23
    Floppy(data) - parses a disk from a given array of bytes.
24
    .open(filename) - loads a file and returns a Floppy from its data.
25
    .save(filename) - saves the disk image to a file.
26
    .flush() - Updates the .data member with any pending changes.
27
    .data - A bytearray of the disk image.
28
 
29
    .files() - Returns a list of strings, each is a file or directory. Directories end with a backslash.
30
    .delete_path(path) - Deletes a file or directory (recursive).
31
    .add_dir_path(path) - Creates a new empty directory, if it does not already exist (recursive). Returns cluster of directory, or -1 if failed.
32
    .add_file_path(path,data) - Creates a new file (creating directory if needed) with the given data. Returns False if failed.
33
    .extract_file_path(path) - Returns a bytearray of the file at the given path, None if not found.
34
    .set_volume_id(id=None) - Sets the 32-bit volume ID. Use with no arguments to generate ID from current time.
35
    .set_volume_label(label) - Sets the 11-character volume label.
36
 
37
    .boot_info() - Returns a string displaying boot sector information.
38
    .fat_info() - Returns a very long string of all 12-bit FAT entries.
39
    .files_info() - Returns a string displaying the files() list.
40
 
41
    .add_all(path,prefix) - Adds all files from local path to disk (uppercased). Use prefix to specify a target directory. Returns False if any failed.
42
    .extract_all(path) - Dumps entire contents of disk to local path.
43
 
44
    .find_path(path) - Returns a Floppy.FileEntry.
45
    FileEntry.info() - Returns and information string about a FileEntry.
46
 
47
    This class provides some basic interface to a FAT12 floppy disk image.
48
    Some things are fragile, in particular filenames that are too long,
49
    or references to clusters outside the disk may cause exceptions.
50
    The FAT can be accessed directly with some internal functions (see implementation)
51
    and changes will be applied to the stored .data image with .flush().
52
 
53
    Example:
54
        f = Floppy.open("disk.img")
55
        print(f.boot_info()) # list boot information about disk
56
        print(f.file_info()) # list files and directories
57
        f.extract_all("disk_dump\\")
58
        f.delete_path("DIR\\FILE.TXT") # delete a file
59
        f.delete_path("DIR") # delete an entire directory
60
        f.add_file_path("DIR\\NEW.TXT",open("new.txt","rb").read()) # add a file, directory automatically created
61
        f.add_dir_path("NEWDIR") # creates a new directory
62
        f.add_all("add\\","ADDED\\") # adds all files from a local directory to to a specified floppy directory.
63
        f.set_volume_id() # generates a new volume ID
64
        f.set_volume_label("MY DISK") # changes the volume label
65
        f.save("new.img")
66
    """
67
 
68
    EMPTY = 0xE5 # incipit value for an empty directory entry
69
 
70
    def _filestring(s, length):
71
        """Creates an ASCII string, padded to length with spaces ($20)."""
72
        b = bytearray(s.encode("ASCII"))
73
        b = b + bytearray([0x20] * (length - len(b)))
74
        if len(b) != length:
75
            raise self.Error("File string '%s' too long? (%d != %d)" % (s,len(b),length))
76
        return b
77
 
78
    class Error(Exception):
79
        """Floppy has had an error."""
80
        pass
81
 
82
    class FileEntry:
83
        """A directory entry for a file."""
84
 
85
        def __init__(self, data=None, dir_cluster=-1, dir_index=-1):
86
            """Unpacks a 32 byte directory entry into a FileEntry structure."""
87
            if data == None:
88
                data = bytearray([Floppy.EMPTY]+([0]*31))
89
            self.data = bytearray(data)
90
            self.path = ""
91
            self.incipit = data[0]
92
            if self.incipit != 0x00 and self.incipit != Floppy.EMPTY:
93
                filename = data[0:8].decode("cp866")
94
                filename = filename.rstrip(" ")
95
                extension = data[8:11].decode("cp866")
96
                extension = extension.rstrip(" ")
97
                self.path = filename
98
                if len(extension) > 0:
99
                    self.path = self.path + "." + extension
100
            block = struct.unpack("
101
            self.attributes = block[0]
102
            self.write_time = block[6]
103
            self.write_date = block[7]
104
            self.cluster = block[8]
105
            self.size = block[9]
106
            self.dir_cluster = dir_cluster
107
            self.dir_index = dir_index
108
 
109
        def compile(self):
110
            """Commits any changed data to the entry, rebuilds and returns the 32 byte structure."""
111
            filename = ""
112
            extension = ""
113
            period = self.path.find(".")
114
            if (period >= 0):
115
                filename = self.path[0:period]
116
                extension = self.path[period+1:]
117
            else:
118
                filename = self.path
119
            if self.incipit != 0x00 and self.incipit != 0xEF:
120
                self.data[0:8] = Floppy._filestring(filename,8)
121
                self.data[8:11] = Floppy._filestring(extension,3)
122
            else:
123
                self.data[0] = self.incipit
124
            self.data[11] = self.attributes
125
            self.data[22:32] = bytearray(struct.pack("
126
                self.write_time,
127
                self.write_date,
128
                self.cluster,
129
                self.size))
130
            return bytearray(self.data)
131
 
132
        def info(self):
133
            """String of information about a FileEntry."""
134
            s = ""
135
            s += "Path: [%s]\n" % self.path
136
            s += "Incipit: %02X\n" % self.incipit
137
            s += "Attributes: %02X\n" % self.attributes
138
            s += "Write: %04X %04X\n" % (self.write_date, self.write_time)
139
            s += "Cluster: %03X\n" % self.cluster
140
            s += "Size: %d bytes\n" % self.size
141
            s += "Directory: %03X, %d\n" % (self.dir_cluster, self.dir_index)
142
            return s
143
 
144
        def fat_time(year, month, day, hour, minute, second):
145
            """Builds a FAT12 date/time entry."""
146
            date = ((year - 1980) << 9) | ((month) << 5) | day
147
            time = (hour << 11) | (minute << 5) | (second >> 1)
148
            return (date, time)
149
 
150
        def fat_time_now():
151
            """Builds the current time as a FAT12 date/time entry."""
152
            now = datetime.datetime.now()
153
            return Floppy.FileEntry.fat_time(now.year, now.month, now.day, now.hour, now.minute, now.second)
154
 
155
        def set_name(self,s):
156
            """Sets the filename and updates incipit."""
157
            self.path = s
158
            self.incipit = Floppy._filestring(s,12)[0]
159
 
160
        def set_now(self):
161
            """Updates modified date/time to now."""
162
            (date,time) = Floppy.FileEntry.fat_time_now()
163
            self.write_date = date
164
            self.write_time = time
165
 
166
        def new_file(name):
167
            """Generate a new file entry."""
168
            e = Floppy.FileEntry()
169
            e.set_name(name)
170
            e.set_now()
171
            e.attributes = 0x00
172
            return e
173
 
174
        def new_dir(name):
175
            """Generate a new subdirectory entry."""
176
            e = Floppy.FileEntry()
177
            e.set_name(name)
178
            e.set_now()
179
            e.attributes = 0x10
180
            return e
181
 
182
        def new_volume(name):
183
            """Generate a new volume entry."""
184
            e = Floppy.FileEntry()
185
            if len(name) > 8:
186
                name = name[0:8] + "." + name[8:]
187
            e.set_name(name)
188
            e.set_now()
189
            e.attributes = 0x08
190
            return e
191
 
192
        def new_terminal():
193
            """Generate a new directory terminating entry."""
194
            e = Floppy.FileEntry()
195
            e.incipit = 0x00
196
            return e
197
 
198
    # blank formatted 1.44MB MS-DOS 5.0 non-system floppy
199
    blank_floppy = [ # boot sector, 2xFAT, "BLANK" volume label, F6 fill
200
        0xEB,0x3C,0x90,0x4D,0x53,0x44,0x4F,0x53,0x35,0x2E,0x30,0x00,0x02,0x01,0x01,0x00,
201
        0x02,0xE0,0x00,0x40,0x0B,0xF0,0x09,0x00,0x12,0x00,0x02,0x00,0x00,0x00,0x00,0x00,
202
        0x00,0x00,0x00,0x00,0x00,0x00,0x29,0xE3,0x16,0x53,0x1B,0x42,0x4C,0x41,0x4E,0x4B,
203
        0x20,0x20,0x20,0x20,0x20,0x20,0x46,0x41,0x54,0x31,0x32,0x20,0x20,0x20,0xFA,0x33,
204
        0xC0,0x8E,0xD0,0xBC,0x00,0x7C,0x16,0x07,0xBB,0x78,0x00,0x36,0xC5,0x37,0x1E,0x56,
205
        0x16,0x53,0xBF,0x3E,0x7C,0xB9,0x0B,0x00,0xFC,0xF3,0xA4,0x06,0x1F,0xC6,0x45,0xFE,
206
        0x0F,0x8B,0x0E,0x18,0x7C,0x88,0x4D,0xF9,0x89,0x47,0x02,0xC7,0x07,0x3E,0x7C,0xFB,
207
        0xCD,0x13,0x72,0x79,0x33,0xC0,0x39,0x06,0x13,0x7C,0x74,0x08,0x8B,0x0E,0x13,0x7C,
208
        0x89,0x0E,0x20,0x7C,0xA0,0x10,0x7C,0xF7,0x26,0x16,0x7C,0x03,0x06,0x1C,0x7C,0x13,
209
        0x16,0x1E,0x7C,0x03,0x06,0x0E,0x7C,0x83,0xD2,0x00,0xA3,0x50,0x7C,0x89,0x16,0x52,
210
        0x7C,0xA3,0x49,0x7C,0x89,0x16,0x4B,0x7C,0xB8,0x20,0x00,0xF7,0x26,0x11,0x7C,0x8B,
211
        0x1E,0x0B,0x7C,0x03,0xC3,0x48,0xF7,0xF3,0x01,0x06,0x49,0x7C,0x83,0x16,0x4B,0x7C,
212
        0x00,0xBB,0x00,0x05,0x8B,0x16,0x52,0x7C,0xA1,0x50,0x7C,0xE8,0x92,0x00,0x72,0x1D,
213
        0xB0,0x01,0xE8,0xAC,0x00,0x72,0x16,0x8B,0xFB,0xB9,0x0B,0x00,0xBE,0xE6,0x7D,0xF3,
214
        0xA6,0x75,0x0A,0x8D,0x7F,0x20,0xB9,0x0B,0x00,0xF3,0xA6,0x74,0x18,0xBE,0x9E,0x7D,
215
        0xE8,0x5F,0x00,0x33,0xC0,0xCD,0x16,0x5E,0x1F,0x8F,0x04,0x8F,0x44,0x02,0xCD,0x19,
216
        0x58,0x58,0x58,0xEB,0xE8,0x8B,0x47,0x1A,0x48,0x48,0x8A,0x1E,0x0D,0x7C,0x32,0xFF,
217
        0xF7,0xE3,0x03,0x06,0x49,0x7C,0x13,0x16,0x4B,0x7C,0xBB,0x00,0x07,0xB9,0x03,0x00,
218
        0x50,0x52,0x51,0xE8,0x3A,0x00,0x72,0xD8,0xB0,0x01,0xE8,0x54,0x00,0x59,0x5A,0x58,
219
        0x72,0xBB,0x05,0x01,0x00,0x83,0xD2,0x00,0x03,0x1E,0x0B,0x7C,0xE2,0xE2,0x8A,0x2E,
220
        0x15,0x7C,0x8A,0x16,0x24,0x7C,0x8B,0x1E,0x49,0x7C,0xA1,0x4B,0x7C,0xEA,0x00,0x00,
221
        0x70,0x00,0xAC,0x0A,0xC0,0x74,0x29,0xB4,0x0E,0xBB,0x07,0x00,0xCD,0x10,0xEB,0xF2,
222
        0x3B,0x16,0x18,0x7C,0x73,0x19,0xF7,0x36,0x18,0x7C,0xFE,0xC2,0x88,0x16,0x4F,0x7C,
223
        0x33,0xD2,0xF7,0x36,0x1A,0x7C,0x88,0x16,0x25,0x7C,0xA3,0x4D,0x7C,0xF8,0xC3,0xF9,
224
        0xC3,0xB4,0x02,0x8B,0x16,0x4D,0x7C,0xB1,0x06,0xD2,0xE6,0x0A,0x36,0x4F,0x7C,0x8B,
225
        0xCA,0x86,0xE9,0x8A,0x16,0x24,0x7C,0x8A,0x36,0x25,0x7C,0xCD,0x13,0xC3,0x0D,0x0A,
226
        0x4E,0x6F,0x6E,0x2D,0x53,0x79,0x73,0x74,0x65,0x6D,0x20,0x64,0x69,0x73,0x6B,0x20,
227
        0x6F,0x72,0x20,0x64,0x69,0x73,0x6B,0x20,0x65,0x72,0x72,0x6F,0x72,0x0D,0x0A,0x52,
228
        0x65,0x70,0x6C,0x61,0x63,0x65,0x20,0x61,0x6E,0x64,0x20,0x70,0x72,0x65,0x73,0x73,
229
        0x20,0x61,0x6E,0x79,0x20,0x6B,0x65,0x79,0x20,0x77,0x68,0x65,0x6E,0x20,0x72,0x65,
230
        0x61,0x64,0x79,0x0D,0x0A,0x00,0x49,0x4F,0x20,0x20,0x20,0x20,0x20,0x20,0x53,0x59,
231
        0x53,0x4D,0x53,0x44,0x4F,0x53,0x20,0x20,0x20,0x53,0x59,0x53,0x00,0x00,0x55,0xAA,
232
        ] + \
233
        [0xF0,0xFF,0xFF] + ([0]*(0x1200-3)) + \
234
        [0xF0,0xFF,0xFF] + ([0]*(0x1200-3)) + [
235
        0x42,0x4C,0x41,0x4E,0x4B,0x20,0x20,0x20,0x20,0x20,0x20,0x28,0x00,0x00,0x00,0x00,
236
        0x00,0x00,0x00,0x00,0x00,0x00,0xEE,0x7C,0x24,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
237
        ] + ([0] * (0x4200-0x2620)) + \
238
        ([0xF6] * (0x168000-0x4200))
239
 
240
    def __init__(self,data=blank_floppy):
241
        """Create Floppy() instance from image bytes, or pre-formatted blank DOS floppy by default."""
242
        self.data = bytearray(data)
243
        self._boot_open()
244
        self._fat_open()
245
 
246
    def _boot_open(self):
247
        """Parses the boot sector."""
248
        if (len(self.data) < 38):
249
            raise self.Error("Not enough data in image for boot sector. (%d bytes)" % len(data))
250
        boot = struct.unpack("
251
        self.sector_size = boot[0]
252
        self.cluster_sects = boot[1]
253
        self.reserved_sects = boot[2]
254
        self.fat_count = boot[3]
255
        self.root_max = boot[4]
256
        self.sectors = boot[5]
257
        self.fat_sects = boot[7]
258
        self.track_sects = boot[8]
259
        self.heads = boot[9]
260
        self.volume_id = 0
261
        self.volume_label = ""
262
        if self.data[38] == 0x29 and len(self.data) >= 54:
263
            self.volume_id = struct.unpack("
264
            self.volume_label = self.data[43:54].decode("ASCII").rstrip(" ")
265
        if (self.sectors * self.sector_size) > len(self.data):
266
            raise self.Error("Not enough data to contain %d x %d byte sectors? (%d bytes)" %
267
                             (self.sectors, self.sector_size, len(self.data)))
268
        self.root = self.sector_size * (self.reserved_sects + (self.fat_count * self.fat_sects))
269
        root_sectors = ((self.root_max * 32) + (self.sector_size-1)) // self.sector_size # round up to fill sector
270
        self.cluster2 = self.root + (self.sector_size * root_sectors)
271
 
272
    def _boot_flush(self):
273
        """Commits changes to the boot sector."""
274
        boot = struct.pack("
275
            self.sector_size,
276
            self.cluster_sects,
277
            self.reserved_sects,
278
            self.fat_count,
279
            self.root_max,
280
            self.sectors,
281
            self.data[21],
282
            self.fat_sects,
283
            self.track_sects,
284
            self.heads)
285
        self.data[11:28] = bytearray(boot)
286
        if self.data[38] == 0x29 and len(self.data) >= 54:
287
            self.data[39:43] = bytearray(struct.pack("
288
            self.data[43:54] = Floppy._filestring(self.volume_label,11)
289
 
290
    def boot_info(self):
291
        """String of information about the boot sector."""
292
        s = ""
293
        s += "Volume Label: [%s]\n" % self.volume_label
294
        s += "Volume ID: %04X\n" % self.volume_id
295
        s += "Sector size: %d bytes\n" % self.sector_size
296
        s += "Cluster size: %d sectors\n" % self.cluster_sects
297
        s += "Reserved sectors: %d\n" % self.reserved_sects
298
        s += "Number of FATs: %d\n" % self.fat_count
299
        s += "Maximum root entries: %d\n" % self.root_max
300
        s += "Total sectors: %d\n" % self.sectors
301
        s += "FAT size: %d sectors\n" % self.fat_sects
302
        s += "Track size: %d sectors\n" % self.track_sects
303
        s += "Heads: %d\n" % self.heads
304
        s += "Root directory: %08X\n" % self.root
305
        s += "Cluster 2: %08X\n" % self.cluster2
306
        return s
307
 
308
    def _fat_open(self):
309
        """Parses the FAT table."""
310
        fat_start = self.reserved_sects * self.sector_size
311
        fat_sects = self.fat_sects * self.sector_size
312
        fat_end = fat_start + fat_sects
313
        # make sure they're in the image
314
        if (self.sectors < (self.reserved_sects + (self.fat_count * self.fat_sects))):
315
            raise self.Error("Not enough sectors to contain %d + %d x %d FAT tables? (%d sectors)" %
316
                            (self.reserved_sects, self.fat_count, self.fat_sects, self.sectors))
317
        if (self.fat_count < 1) or (self.fat_sects < 1):
318
            raise self.Error("No FAT tables? (%d x %d FAT sectors)" %
319
                             (self.fat_count, self.fat_sects))
320
        # verify FAT tables match
321
        for i in range(1,self.fat_count):
322
            fat2_start = fat_start + (fat_sects * i)
323
            fat2_end = fat2_start + fat_sects
324
            if self.data[fat_start:fat_end] != self.data[fat2_start:fat2_end]:
325
                raise self.Error("FAT mismatch in table %d." % i)
326
        # read FAT 0
327
        self.fat = []
328
        e = fat_start
329
        while (e+2) <= fat_end:
330
            entry = 0
331
            if (len(self.fat) & 1) == 0:
332
                entry = self.data[e+0] | ((self.data[e+1] & 0x0F) << 8)
333
                e += 1
334
            else:
335
                entry = ((self.data[e+0] & 0xF0) >> 4) | (self.data[e+1] << 4)
336
                e += 2
337
            self.fat.append(entry)
338
 
339
    def _fat_flush(self):
340
        """Commits changes to the FAT table."""
341
        fat_start = self.reserved_sects * self.sector_size
342
        fat_sects = self.fat_sects * self.sector_size
343
        fat_end = fat_start + fat_sects
344
        # build FAT 0
345
        e = self.reserved_sects * self.sector_size
346
        for i in range(len(self.fat)):
347
            entry = self.fat[i]
348
            if (i & 1) == 0:
349
                self.data[e+0] = entry & 0xFF
350
                self.data[e+1] = (self.data[e+1] & 0xF0) | ((entry >> 8) & 0x0F)
351
                e += 1
352
            else:
353
                self.data[e+0] = (self.data[e+0] & 0x0F) | ((entry << 4) & 0xF0)
354
                self.data[e+1] = (entry >> 4) & 0xFF
355
                e += 2
356
        # copy to all tables
357
        for i in range(1,self.fat_count):
358
            fat2_start = fat_start + (fat_sects * i)
359
            fat2_end = fat2_start + fat_sects
360
            self.data[fat2_start:fat2_end] = self.data[fat_start:fat_end]
361
 
362
    def fat_info(self):
363
        """String of information about the FAT."""
364
        per_line = 32
365
        s = ""
366
        for i in range(len(self.fat)):
367
            if (i % per_line) == 0:
368
                s += "%03X: " % i
369
            if (i % per_line) == (per_line // 2):
370
                s += "  " # extra space to mark 16
371
            s += " %03X" % self.fat[i]
372
            if (i % per_line) == (per_line - 1):
373
                s += "\n"
374
        return s
375
 
376
    def _cluster_offset(self, cluster):
377
        """Image offset of a FAT indexed logical cluster."""
378
        if cluster < 2:
379
            return self.root
380
        return self.cluster2 + (self.sector_size * self.cluster_sects * (cluster-2))
381
 
382
    def _read_chain(self, cluster, size):
383
        data = bytearray()
384
        """Returns up to size bytes from a chain of clusters starting at cluster."""
385
        while cluster < 0xFF0:
386
            offset = self._cluster_offset(cluster)
387
            #print("read_chain(%03X,%d) at %08X" % (cluster,size,offset))
388
            if cluster < 2:
389
                return data + bytearray(self.data[self.root:self.root+size]) # root directory
390
            read = min(size,(self.sector_size * self.cluster_sects))
391
            data = data + bytearray(self.data[offset:offset+read])
392
            size -= read
393
            if (size < 1):
394
                return data
395
            cluster = self.fat[cluster]
396
        return data
397
 
398
    def _read_dir_chain(self, cluster):
399
        """Reads an entire directory chain starting at the given cluster (0 for root)."""
400
        if cluster < 2:
401
            # root directory is contiguous
402
            return self.data[self.root:self.root+(self.root_max*32)]
403
        # directories just occupy as many clusters as in their FAT chain, using a dummy max size
404
        return self._read_chain(cluster, self.sector_size*self.sectors//self.cluster_sects)
405
 
406
    def _delete_chain(self, cluster):
407
        """Deletes a FAT chain."""
408
        while cluster < 0xFF0 and cluster >= 2:
409
            link = self.fat[cluster]
410
            self.fat[cluster] = 0
411
            cluster = link
412
 
413
    def _add_chain(self,data):
414
        """Adds a block of data to the disk and creates its FAT chain. Returns start cluster, or -1 for failure."""
415
        cluster_size = self.sector_size * self.cluster_sects
416
        clusters = (len(data) + cluster_size-1) // cluster_size
417
        if clusters < 1:
418
            clusters = 1
419
        # find a chain of free clusters
420
        chain = []
421
        for i in range(2,len(self.fat)):
422
            if self.fat[i] == 0:
423
                chain.append(i)
424
            if len(chain) >= clusters:
425
                break
426
        if len(chain) < clusters:
427
            return -1 # out of space
428
        # store the FAT chain
429
        start_cluster = chain[0]
430
        for i in range(0,len(chain)-1):
431
            self.fat[chain[i]] = chain[i+1]
432
        self.fat[chain[len(chain)-1]] = 0xFFF
433
        # store the data in the given clusters
434
        data = bytearray(data)
435
        c = 0
436
        while len(data) > 0:
437
            write = min(len(data),cluster_size)
438
            offset = self._cluster_offset(chain[c])
439
            self.data[offset:offset+write] = data[0:write]
440
            c += 1
441
            data = data[write:]
442
        # done
443
        return start_cluster
444
 
445
    def _files_dir(self, cluster, path):
446
        """Returns a list of files in the directory starting at the given cluster. Recursive."""
447
        #print("files_dir(%03X,'%s')" % (cluster, path))
448
        entries = []
449
        directory = self._read_dir_chain(cluster)
450
        for i in range(len(directory) // 32):
451
            e = self.FileEntry(directory[(i*32):(i*32)+32],cluster,i)
452
            #print(("entry %d (%08X)\n"%(i,self._dir_entry_offset(cluster,i)))+e.info())
453
            if e.incipit == 0x00: # end of directory
454
                return entries
455
            if e.incipit == Floppy.EMPTY: # empty
456
                continue
457
            if (e.attributes & 0x08) != 0: # volume label
458
                continue
459
            if (e.attributes & 0x10) != 0: # subdirectory
460
                if e.path == "." or e.path == "..":
461
                    continue
462
                subdir = path + e.path + "\\"
463
                entries.append(subdir)
464
                entries = entries + self._files_dir(e.cluster,subdir)
465
            else:
466
                entries.append(path + e.path)
467
        return entries
468
 
469
    def files(self):
470
        """Returns a list of files in the image."""
471
        root = self.sector_size * (self.reserved_sects + (self.fat_count * self.fat_sects))
472
        return self._files_dir(0,"")
473
 
474
    def files_info(self):
475
        """String of the file list."""
476
        s = ""
477
        for path in self.files():
478
            s += path + "\n"
479
        return s
480
 
481
    def _dir_entry_offset(self,cluster,dir_index):
482
        """Find the offset in self.data to a particular directory entry."""
483
        if (cluster < 2):
484
            return self.root + (32 * dir_index)
485
        if (cluster >= 0xFF0):
486
            raise self.Error("Directory entry %d not in its cluster chain?" % dir_index)
487
        per_cluster = (self.sector_size*self.cluster_sects)//32
488
        if (dir_index < per_cluster): # within this cluster
489
            return self._cluster_offset(cluster) + (32 * dir_index)
490
        # continue to next cluster
491
        return self._dir_entry_offset(self.fat[cluster],dir_index-per_cluster)
492
 
493
    def delete_file(self, entry):
494
        """Deletes a FileEntry."""
495
        self._delete_chain(entry.cluster) # delete its FAT chain
496
        offset = self._dir_entry_offset(entry.dir_cluster,entry.dir_index)
497
        self.data[offset+0] = Floppy.EMPTY # empty this entry
498
 
499
    def _find_path_dir(self, cluster, path):
500
        """Recursive find path, breaking out subdirectories progressively."""
501
        #print("_find_path_dir(%03X,'%s')" % (cluster,path))
502
        separator = path.find("\\")
503
        path_seek = path
504
        path_next = ""
505
        if separator >= 0:
506
            path_seek = path[0:separator]
507
            path_next = path[separator+1:]
508
        directory = self._read_dir_chain(cluster)
509
        for i in range(len(directory) // 32):
510
            e = self.FileEntry(directory[(i*32):(i*32)+32],cluster,i)
511
            if e.incipit == 0x00: # end of directory
512
                return None
513
            if e.incipit == Floppy.EMPTY: # empty
514
                continue
515
            if (e.attributes & 0x08) != 0: # volume label
516
                continue
517
            if (e.attributes & 0x10) != 0: # subdirectory
518
                if e.path == "." or e.path == "..":
519
                    continue
520
                if e.path == path_seek:
521
                    if (len(path_next) > 0):
522
                        return self._find_path_dir(e.cluster, path_next)
523
                    else:
524
                        return e
525
            elif e.path == path_seek and path_next == "":
526
                return e
527
        return None
528
 
529
    def find_path(self, path):
530
        """Finds a FileEntry for a given path."""
531
        return self._find_path_dir(0,path)
532
 
533
    def _delete_tree(self,de):
534
        """Recursively deletes directory entries."""
535
        #print("_delete_tree\n" + de.info())
536
        directory = self._read_dir_chain(de.cluster)
537
        for i in range(len(directory) // 32):
538
            e = self.FileEntry(directory[(i*32):(i*32)+32],de.cluster,i)
539
            #print(e.info())
540
            if e.incipit == 0x00: # end of directory
541
                return
542
            if e.incipit == Floppy.EMPTY: # empty
543
                continue
544
            if (e.attributes & 0x08) != 0: # volume label
545
                continue
546
            if (e.attributes & 0x10) != 0: # subdirectory
547
                if e.path == "." or e.path == "..":
548
                    continue
549
                self._delete_tree(e) # recurse
550
                self.delete_file(e)
551
            else:
552
                self.delete_file(e)
553
 
554
    def delete_path(self, path):
555
        """Finds a file or directory and deletes it (recursive), if it exists. Returns True if successful."""
556
        e = self.find_path(path)
557
        if (e == None):
558
            return False
559
        if (e.attributes & 0x10) != 0:
560
            self._delete_tree(e)
561
        self.delete_file(e)
562
        return True
563
 
564
    def _add_entry(self, dir_cluster, entry):
565
        """
566
        Adds an entry to a directory starting at the given cluster, appending a new cluster if needed.
567
        Sets entry.dir_cluster and entry.dir_index to match their new directory.
568
        Returns False if out of space.
569
        """
570
        #print(("_add_entry(%d):\n"%dir_cluster) + entry.info())
571
        directory = self._read_dir_chain(dir_cluster)
572
        dir_len = len(directory)//32
573
        i = 0
574
        terminal = False
575
        while i < dir_len:
576
            e = self.FileEntry(directory[(i*32):(i*32)+32],dir_cluster,i)
577
            #print(e.info())
578
            if e.incipit == 0x00:
579
                terminal = True # make sure to add another terminal entry after this one
580
                break
581
            if e.incipit == Floppy.EMPTY:
582
                break
583
            i += 1
584
        # extend directory if out of room
585
        if i >= dir_len:
586
            if dir_cluster < 2:
587
                return False # no room in root
588
            # add a zero-filled page to the end of this directory's FAT chain
589
            chain = self._add_chain(bytearray([0]*(self.sector_size*self.cluster_sects)))
590
            if (chain < 0):
591
                return False # no free clusters
592
            tail = dir_cluster
593
            while self.fat[tail] < 0xFF0:
594
                tail = self.fat[tail]
595
            self.fat[tail] = chain
596
            self.fat[chain] = 0xFFF
597
        # insert entry
598
        entry.dir_cluster = dir_cluster
599
        entry.dir_index = i
600
        offset = self._dir_entry_offset(dir_cluster,i)
601
        self.data[offset:offset+32] = entry.compile()
602
        # add a new terminal if needed
603
        if terminal:
604
            i += 1
605
            if i < dir_len: # if it was the last entry, no new terminal is needed
606
                offset = self._dir_entry_offset(dir_cluster,i)
607
                self.data[offset:offset+32] = Floppy.FileEntry.new_terminal().compile()
608
 
609
        # success!
610
        return True
611
 
612
    def _add_dir_recursive(self, cluster, path):
613
        """Recursively creates directory, returns cluster of created dir, -1 if failed."""
614
        #print("_add_dir_recursive(%03X,'%s')"%(cluster,path))
615
        separator = path.find("\\")
616
        path_seek = path
617
        path_next = ""
618
        if separator >= 0:
619
            path_seek = path[0:separator]
620
            path_next = path[separator+1:]
621
        directory = self._read_dir_chain(cluster)
622
        for i in range(len(directory) // 32):
623
            e = self.FileEntry(directory[(i*32):(i*32)+32],cluster,i)
624
            #print(e.info())
625
            if e.incipit == 0x00: # end of directory
626
                break
627
            if e.incipit == Floppy.EMPTY: # empty
628
                continue
629
            if (e.attributes & 0x10) != 0: # subdirectory
630
                if e.path == path_seek: # already exists
631
                    if len(path_next) < 1:
632
                        return e.cluster # return existing directory
633
                    else:
634
                        return self._add_dir_recursive(e.cluster,path_next) # keep descending
635
        # not found: create the directory
636
        dp0 = Floppy.FileEntry.new_dir("_") # .
637
        dp1 = Floppy.FileEntry.new_dir("__") # ..
638
        dp1.cluster = cluster
639
        dir_block = dp0.compile() + dp1.compile() + bytearray([0] * ((self.sector_size*self.cluster_sects)-64))
640
        new_cluster = self._add_chain(dir_block)
641
        if new_cluster < 0:
642
            return -1 # out of space
643
        # fix up "." to point to itself
644
        dp0.cluster = new_cluster
645
        offset = self._dir_entry_offset(new_cluster,0)
646
        self.data[offset:offset+32] = dp0.compile()
647
        # fix special directory names (FileEntry.compile() is incapable of . or ..)
648
        self.data[offset+ 0:offset+11] = bytearray(".          ".encode("ASCII"))
649
        self.data[offset+32:offset+43] = bytearray("..         ".encode("ASCII"))
650
        # create entry to point to new directory cluster
651
        new_dir = Floppy.FileEntry.new_dir(path_seek)
652
        new_dir.cluster = new_cluster
653
        if not self._add_entry(cluster, new_dir):
654
            self._delete_chain(new_cluster)
655
            return -1 # out of space
656
        # return the entry if tail is reached, or keep descending
657
        if len(path_next) < 1:
658
            return new_cluster
659
        else:
660
            return self._add_dir_recursive(new_cluster,path_next)
661
 
662
    def add_dir_path(self, path):
663
        """
664
        Recursively ensures that the given directory path exists, creating it if necessary.
665
        Path should not end in backslash.
666
        Returns cluster of directory at path, or -1 if failed.
667
        """
668
        if (len(path) < 1):
669
            return 0 # root
670
        return self._add_dir_recursive(0,path)
671
 
672
    def add_file_path(self, path, data):
673
        """
674
        Adds the given data as a file at the given path.
675
        Will automatically create directories to complete the path.
676
        Returns False if failed.
677
        """
678
        self.delete_path(path) # remove file if it already exists
679
        dir_path = ""
680
        file_path = path
681
        separator = path.rfind("\\")
682
        if (separator >= 0):
683
            dir_path = path[0:separator]
684
            file_path = path[separator+1:]
685
        dir_cluster = self.add_dir_path(dir_path)
686
        if dir_cluster < 0:
687
            return False # couldn't find or create directory
688
        cluster = self._add_chain(data)
689
        if (cluster < 0):
690
            return False # out of space
691
        entry = Floppy.FileEntry.new_file(file_path)
692
        entry.cluster = cluster
693
        entry.size = len(data)
694
        if not self._add_entry(dir_cluster, entry):
695
            self._delete_chain(cluster)
696
            return False # out of space for directory entry
697
        offset = self._dir_entry_offset(entry.dir_cluster, entry.dir_index)
698
        self.data[offset:offset+32] = entry.compile()
699
        return True
700
 
701
    def extract_file_path(self, path):
702
        """Finds a file and returns all of its data. None on failure."""
703
        e = self.find_path(path)
704
        if e == None:
705
            return None
706
        return self._read_chain(e.cluster, e.size)
707
 
708
    def set_volume_id(self, value=None):
709
        """Sets the volume ID. None for time-based."""
710
        if (value == None):
711
            (date,time) = Floppy.FileEntry.fat_time_now()
712
            value = time | (date << 16)
713
        self.volume_id = value & 0xFFFFFFFF
714
 
715
    def set_volume_label(self, label):
716
        """Sets the volume label. Creates one if necessary."""
717
        self.data[38] = 0x29
718
        self.volume_label = label
719
        self.data[54:62] = Floppy._filestring("FAT12",8)
720
        # adjust existing volume entry in root
721
        directory = self._read_dir_chain(0)
722
        for i in range(len(directory) // 32):
723
            e = self.FileEntry(directory[(i*32):(i*32)+32],0,i)
724
            if e.incipit == 0x00: # end of directory
725
                break
726
            if e.incipit == Floppy.EMPTY: # empty
727
                continue
728
            if (e.attributes & 0x08) != 0: # existing volume label
729
                offset = self._dir_entry_offset(0,i)
730
                self.data[offset:offset+11] = Floppy._filestring(label,11)
731
                return
732
        # volume entry does not exist in root, add one
733
        self._add_entry(0,Floppy.FileEntry.new_volume(label))
734
        return
735
 
736
    def open(filename):
737
        """Opens a floppy image file and creates a Floppy() instance from it."""
738
        return Floppy(open(filename,"rb").read())
739
 
740
    def flush(self):
741
        """Commits all unfinished changes to self.data image."""
742
        self._fat_flush()
743
        self._boot_flush()
744
 
745
    def save(self, filename):
746
        """Saves image (self.data) to file. Implies flush()."""
747
        self.flush()
748
        open(filename,"wb").write(self.data)
749
 
750
    def extract_all(self, out_directory):
751
        """Extracts all files from image to specified directory."""
752
        for in_path in self.files():
753
            out_path = os.path.join(out_directory,in_path)
754
            out_dir = os.path.dirname(out_path)
755
            if not os.path.exists(out_dir):
756
                try:
757
                    os.makedirs(out_dir)
758
                except OSError as e:
759
                    if e.errono != errno.EEXIST:
760
                        raise
761
            if not in_path.endswith("\\"):
762
                open(out_path,"wb").write(self.extract_file_path(in_path))
763
                print(out_path)
764
            else:
765
                print(out_path)
766
 
767
    def add_all(self, in_directory, prefix=""):
768
        """
769
        Adds all files from specified directory to image.
770
        Files will be uppercased. Long filenames are not checked and will cause an exception.
771
        prefix can be used to prefix a directory path (ending with \) to the added files.
772
        """
773
        result = True
774
        def dospath(s):
775
            s = s.upper()
776
            s = s.replace("/","\\")
777
            return s
778
        if len(in_directory) < 1:
779
            in_directory = "."
780
        in_directory = os.path.normpath(in_directory) + os.sep
781
        for (root, dirs, files) in os.walk(in_directory):
782
            base = root[len(in_directory):]
783
            for d in dirs:
784
                dir_path = prefix + dospath(os.path.join(base,d))
785
                result = result and (self.add_dir_path(dir_path) >= 0)
786
                print(dir_path + "\\")
787
            for f in files:
788
                file_path = prefix + dospath(os.path.join(base,f))
789
                data = open(os.path.join(root,f),"rb").read()
790
                result = result and self.add_file_path(file_path,data)
791
                print(file_path + " (%d bytes)" % len(data))
792
        return result