Ticket #2864: uc1541

File uc1541, 18.2 KB (added by gryf, 12 years ago)

proposed uc1541 replacement

Line 
1#! /usr/bin/env python
2"""
3UC1541 Virtual filesystem
4
5This extfs provides an access to disk image files for the Commodore
6VIC20/C64/C128. It requires the utility c1541 that comes bundled with Vice,
7the emulator for the VIC20, C64, C128 and other computers made by Commodore.
8
9Remarks
10-------
11
12Due to different way of representing file entries on regular D64 disk images,
13there could be issues with filenames that are transfered from/to the image.
14Following rules was applied to represent a single file entry:
15
161. An extension is attached to the end of a filename depending on a file type.
17   Possible extensions are: prg, del, seq, usr and rel.
182. Every non-ASCII character (which could be some of characters specific to
19   PET-ASCII, or be a control character) will be replaced by dot (.), since
20   c1541 program will list them that way.
213. Every slash character (/) will be replaced by pipe character (|).
224. Leading space will be replaced by tilda (~).
23
24While copying from D64 image to filesystem, filenames will be stored as they
25are seen on a listing.
26
27While copying from filesystem to D64 image, filename conversion will be done:
281. Every $ and * characters will be replaced by question mark (?)
292. Every pipe (|) and backslash (\) characters will be replaced by slash (/)
303. Every tilda (~) will be replaced by a space
314. 'prg' extension will be truncated
32
33Representation of a directory can be sometimes confusing - in case when one
34copied file without extension it stays there in such form, till next access
35(after flushing VFS). Also file sizes are not accurate, since D64 directory
36entries have sizes stored as 256 bytes blocks.
37
38Configuration
39-------------
40
41Here are specific for this script variable, which while set, can influence
42script behaviour:
43
44UC1541_DEBUG - if set, uc1541 will produce log in /tmp/uc1541.log file
45
46UC1541_VERBOSE - of set, script will be more verbose, i.e. error messages form
47c1541 program will be passed to Midnight Commander, so that user will be aware
48of error cause if any.
49
50UC1541_HIDE_DEL - if set, no DEL entries will be shown
51
52Changelog:
53    2.3 Re added and missing method _correct_fname used for writing files
54        into d64 image.
55    2.2 Fixed bug(?) with unusual sector end (marked as sector 0, not 255),
56        causing endless directory reading on random locations.
57    2.1 Fixed bug with filenames containing slash.
58    2.0 Added reading raw D64 image, and mapping for jokers. Now it is
59        possible to read files with PET-ASCII/control sequences in filenames.
60        Working with d64 images only. Added workaround for space at the
61        beggining of the filename.
62    1.2 Added configuration env variables: UC1541_VERBOSE and UC1541_HIDE_DEL.
63        First one, if set to any value, will cause that error messages from
64        c1541 program will be redirected as a failure messages visible in MC.
65        The other variable, when set to any value, cause "del" entries to be
66        not shown in the lister.
67    1.1 Added protect bits, added failsafe for argparse module
68    1.0 Initial release
69
70Author: Roman 'gryf' Dobosz <gryf73@gmail.com>
71Date: 2012-09-24
72Version: 2.3
73Licence: BSD
74"""
75
76import sys
77import re
78import os
79from subprocess import Popen, PIPE
80
81if os.getenv('UC1541_DEBUG'):
82    import logging
83    LOG = logging.getLogger('UC1541')
84    LOG.setLevel(logging.DEBUG)
85    FILE_HANDLER = logging.FileHandler("/tmp/uc1541.log")
86    FILE_FORMATTER = logging.Formatter("%(asctime)s %(levelname)-8s "
87                                       "%(lineno)s %(funcName)s - %(message)s")
88    FILE_HANDLER.setFormatter(FILE_FORMATTER)
89    FILE_HANDLER.setLevel(logging.DEBUG)
90    LOG.addHandler(FILE_HANDLER)
91else:
92    class LOG(object):
93        """
94        Dummy logger object. does nothing.
95        """
96        @classmethod
97        def debug(*args, **kwargs):
98            pass
99
100        @classmethod
101        def info(*args, **kwargs):
102            pass
103
104        @classmethod
105        def warning(*args, **kwargs):
106            pass
107
108        @classmethod
109        def error(*args, **kwargs):
110            pass
111
112        @classmethod
113        def critical(*args, **kwargs):
114            pass
115
116
117class D64(object):
118    """
119    Implement d64 directory reader
120    """
121    CHAR_MAP = {32: ' ', 33: '!', 34: '"', 35: '#', 37: '%', 38: '&', 39: "'",
122                40: '(', 41: ')', 42: '*', 43: '+', 44: ',', 45: '-', 46: '.',
123                47: '/', 48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5',
124                54: '6', 55: '7', 56: '8', 57: '9', 59: ';', 60: '<', 61: '=',
125                62: '>', 63: '?', 64: '@', 65: 'a', 66: 'b', 67: 'c', 68: 'd',
126                69: 'e', 70: 'f', 71: 'g', 72: 'h', 73: 'i', 74: 'j', 75: 'k',
127                76: 'l', 77: 'm', 78: 'n', 79: 'o', 80: 'p', 81: 'q', 82: 'r',
128                83: 's', 84: 't', 85: 'u', 86: 'v', 87: 'w', 88: 'x', 89: 'y',
129                90: 'z', 91: '[', 93: ']', 97: 'A', 98: 'B', 99: 'C',
130                100: 'D', 101: 'E', 102: 'F', 103: 'G', 104: 'H', 105: 'I',
131                106: 'J', 107: 'K', 108: 'L', 109: 'M', 110: 'N', 111: 'O',
132                112: 'P', 113: 'Q', 114: 'R', 115: 'S', 116: 'T', 117: 'U',
133                118: 'V', 119: 'W', 120: 'X', 121: 'Y', 122: 'Z', 193: 'A',
134                194: 'B', 195: 'C', 196: 'D', 197: 'E', 198: 'F', 199: 'G',
135                200: 'H', 201: 'I', 202: 'J', 203: 'K', 204: 'L', 205: 'M',
136                206: 'N', 207: 'O', 208: 'P', 209: 'Q', 210: 'R', 211: 'S',
137                212: 'T', 213: 'U', 214: 'V', 215: 'W', 216: 'X', 217: 'Y',
138                218: 'Z'}
139
140    FILE_TYPES = {0b000: 'del',
141                  0b001: 'seq',
142                  0b010: 'prg',
143                  0b011: 'usr',
144                  0b100: 'rel'}
145
146    def __init__(self, dimage):
147        """
148        Init
149        """
150        LOG.debug('image: %s', dimage)
151        dimage = open(dimage, 'rb')
152        self.raw = dimage.read()
153        dimage.close()
154
155        self.current_sector_data = None
156        self.next_sector = 0
157        self.next_track = None
158        self._dir_contents = []
159
160    def _map_filename(self, string):
161        """
162        Transcode filename to ASCII compatible. Replace not supported
163        characters with jokers.
164        """
165
166        filename = list()
167
168        for chr_ in string:
169            if ord(chr_) == 160:  # shift+space character; $a0
170                break
171
172            character = D64.CHAR_MAP.get(ord(chr_), '?')
173            filename.append(character)
174
175        LOG.debug("string: ``%s'' mapped to: ``%s''", string,
176                  "".join(filename))
177        return "".join(filename)
178
179    def _go_to_next_sector(self):
180        """
181        Fetch (if exist) next sector from a directory chain
182        Return False if the chain ends, True otherwise
183        """
184
185        if self.next_track == 0 and self.next_sector in (0, 255):
186            LOG.debug("End of directory")
187            return False
188
189        if self.next_track is None:
190            LOG.debug("Going to the track: 18,1")
191            offset = self._get_d64_offset(18, 1)
192        else:
193            offset = self._get_d64_offset(self.next_track, self.next_sector)
194            LOG.debug("Going to the track: %s,%s", self.next_track,
195                      self.next_sector)
196
197        self.current_sector_data = self.raw[offset:offset + 256]
198
199        self.next_track = ord(self.current_sector_data[0])
200        self.next_sector = ord(self.current_sector_data[1])
201        LOG.debug("Next track: %s,%s", self.next_track, self.next_sector)
202        return True
203
204    def _get_ftype(self, num):
205        """
206        Get filetype as a string
207        """
208        return D64.FILE_TYPES.get(int("%d%d%d" % (num & 4 and 1,
209                                                  num & 2 and 1,
210                                                  num & 1), 2), '???')
211
212    def _get_d64_offset(self, track, sector):
213        """
214        Return offset (in bytes) for specified track and sector.
215        """
216
217        offset = 0
218        truncate_track = 0
219
220        if track > 17:
221            offset = 17 * 21 * 256
222            truncate_track = 17
223
224        if track > 24:
225            offset += 6 * 19 * 256
226            truncate_track = 24
227
228        if track > 30:
229            offset += 5 * 18 * 256
230            truncate_track = 30
231
232        track = track - truncate_track
233        offset += track * sector * 256
234
235        return offset
236
237    def _harvest_entries(self):
238        """
239        Traverse through sectors and store entries in _dir_contents
240        """
241        sector = self.current_sector_data
242        for x in range(8):
243            entry = sector[:32]
244            ftype = ord(entry[2])
245
246            if ftype == 0:  # deleted
247                sector = sector[32:]
248                continue
249
250            type_verbose = self._get_ftype(ftype)
251
252            protect = ord(entry[2]) & 64 and "<" or " "
253            fname = entry[5:21]
254            if ftype == 'rel':
255                size = ord(entry[23])
256            else:
257                size = ord(entry[30]) + ord(entry[31]) * 226
258
259            self._dir_contents.append({'fname': self._map_filename(fname),
260                                       'ftype': type_verbose,
261                                       'size': size,
262                                       'protect': protect})
263            sector = sector[32:]
264
265    def list_dir(self):
266        """
267        Return directory list as list of dict with keys:
268            fname, ftype, protect and size
269        """
270        while self._go_to_next_sector():
271            self._harvest_entries()
272
273        return self._dir_contents
274
275
276class Uc1541(object):
277    """
278    Class for interact with c1541 program and MC
279    """
280    PRG = re.compile(r'(\d+)\s+"([^"]*)".+?\s(del|prg|rel|seq|usr)([\s<])')
281
282    def __init__(self, archname):
283        self.arch = archname
284        self.out = ''
285        self.err = ''
286        self._verbose = os.getenv("UC1541_VERBOSE", False)
287        self._hide_del = os.getenv("UC1541_HIDE_DEL", False)
288
289        self.pyd64 = D64(archname).list_dir()
290        self.file_map = {}
291        self.directory = []
292
293    def list(self):
294        """
295        Output list contents of D64 image.
296        Convert filenames to be Unix filesystem friendly
297        Add suffix to show user what kind of file do he dealing with.
298        """
299        LOG.info("List contents of %s", self.arch)
300        directory = self._get_dir()
301
302        for entry in directory:
303            sys.stdout.write("%(perms)s   1 %(uid)-8d %(gid)-8d %(size)8d "
304                             "Jan 01 1980 %(display_name)s\n" % entry)
305        return 0
306
307    def rm(self, dst):
308        """
309        Remove file from D64 image
310        """
311        LOG.info("Removing file %s", dst)
312        dst = self._get_masked_fname(dst)
313
314        if not self._call_command('delete', dst=dst):
315            return self._show_error()
316
317        # During removing, a message containing ERRORCODE is sent to stdout
318        # instead of stderr. Everything other than 'ERRORCODE 1' (which means:
319        # 'everything fine') is actually a failure. In case of verbose error
320        # output it is needed to copy self.out to self.err.
321        if '\nERRORCODE 1\n' not in self.out:
322            self.err = self.out
323            return self._show_error()
324
325        return 0
326
327    def copyin(self, dst, src):
328        """
329        Copy file to the D64 image. Destination filename has to be corrected.
330        """
331        LOG.info("Copy into D64 %s as %s", src, dst)
332        dst = self._correct_fname(dst)
333
334        if not self._call_command('write', src=src, dst=dst):
335            return self._show_error()
336
337        return 0
338
339    def copyout(self, src, dst):
340        """
341        Copy file form the D64 image. Source filename has to be corrected,
342        since it's representation differ from the real one inside D64 image.
343        """
344        LOG.info("Copy form D64 %s as %s", src, dst)
345        if not src.endswith(".prg"):
346            return "cannot read"
347
348        src = self._get_masked_fname(src)
349
350        if not self._call_command('read', src=src, dst=dst):
351            return self._show_error()
352
353        return 0
354
355    def _correct_fname(self, fname):
356        """
357        Return filename with mapped characters, without .prg extension.
358        Characters like $, *, + in filenames are perfectly legal, but c1541
359        program seem to have issues with it while writing, so it will also be
360        replaced.
361        """
362        char_map = {'|': "/",
363                    "\\": "/",
364                    "~": " ",
365                    "$": "?",
366                    "*": "?"}
367
368        if fname.lower().endswith(".prg"):
369            fname = fname[:-4]
370
371        new_fname = []
372        for char in fname:
373            trans = char_map.get(char)
374            new_fname.append(trans if trans else char)
375
376        return "".join(new_fname)
377
378    def _get_masked_fname(self, fname):
379        """
380        Return masked filename with '?' jokers instead of non ASCII
381        characters, useful for copying or deleting files with c1541. In case
382        of several files with same name exists in directory, only first one
383        will be operative (first as appeared in directory).
384
385        Warning! If there are two different names but the only difference is in
386        non-ASCII characters (some PET ASCII or control characters) there is
387        a risk that one can remove both files.
388        """
389        directory = self._get_dir()
390
391        for entry in directory:
392            if entry['display_name'] == fname:
393                return entry['pattern_name']
394
395    def _get_dir(self):
396        """
397        Retrieve directory via c1541 program
398        """
399        directory = []
400
401        uid = os.getuid()
402        gid = os.getgid()
403
404        if not self._call_command('list'):
405            return self._show_error()
406
407        idx = 0
408        for line in self.out.split("\n"):
409            if Uc1541.PRG.match(line):
410                blocks, fname, ext, rw = Uc1541.PRG.match(line).groups()
411
412                if ext == 'del' and self._hide_del:
413                    continue
414
415                display_name = ".".join([fname, ext])
416                pattern_name = self.pyd64[idx]['fname']
417
418                if '/' in display_name:
419                    display_name = display_name.replace('/', '|')
420
421                # workaround for space at the beggining of the filename
422                if display_name[0] == ' ':
423                    display_name = '~' + display_name[1:]
424
425                if ext == 'del':
426                    perms = "----------"
427                else:
428                    perms = "-r%s-r--r--" % (rw.strip() and "-" or "w")
429
430                directory.append({'pattern_name': pattern_name,
431                                  'display_name': display_name,
432                                  'uid': uid,
433                                  'gid': gid,
434                                  'size': int(blocks) * 256,
435                                  'perms': perms})
436                idx += 1
437        return directory
438
439    def _show_error(self):
440        """
441        Pass out error output from c1541 execution
442        """
443        if self._verbose:
444            sys.exit(self.err)
445        else:
446            sys.exit(1)
447
448    def _call_command(self, cmd, src=None, dst=None):
449        """
450        Return status of the provided command, which can be one of:
451            write
452            read
453            delete
454            dir/list
455        """
456        command = ['c1541', '-attach', self.arch, '-%s' % cmd]
457        if src and dst:
458            command.append(src)
459            command.append(dst)
460        elif src or dst:
461            command.append(src and src or dst)
462
463        self.out, self.err = Popen(command, stdout=PIPE,
464                                   stderr=PIPE).communicate()
465        return not self.err
466
467
468CALL_MAP = {'list': lambda a: Uc1541(a.ARCH).list(),
469            'copyin': lambda a: Uc1541(a.ARCH).copyin(a.SRC, a.DST),
470            'copyout': lambda a: Uc1541(a.ARCH).copyout(a.SRC, a.DST),
471            'rm': lambda a: Uc1541(a.ARCH).rm(a.DST)}
472
473
474def parse_args():
475    """
476    Use ArgumentParser to check for script arguments and execute.
477    """
478    parser = ArgumentParser()
479    subparsers = parser.add_subparsers(help='supported commands')
480    parser_list = subparsers.add_parser('list', help="List contents of D64 "
481                                        "image")
482    parser_copyin = subparsers.add_parser('copyin', help="Copy file into D64 "
483                                          "image")
484    parser_copyout = subparsers.add_parser('copyout', help="Copy file out of "
485                                           "D64 image")
486    parser_rm = subparsers.add_parser('rm', help="Delete file from D64 image")
487
488    parser_list.add_argument('ARCH', help="D64 Image filename")
489    parser_list.set_defaults(func=CALL_MAP['list'])
490
491    parser_copyin.add_argument('ARCH', help="D64 Image filename")
492    parser_copyin.add_argument('SRC', help="Source filename")
493    parser_copyin.add_argument('DST', help="Destination filename (to be "
494                               "written into D64 image)")
495    parser_copyin.set_defaults(func=CALL_MAP['copyin'])
496
497    parser_copyout.add_argument('ARCH', help="D64 Image filename")
498    parser_copyout.add_argument('SRC', help="Source filename (to be read from"
499                                " D64 image")
500    parser_copyout.add_argument('DST', help="Destination filename")
501    parser_copyout.set_defaults(func=CALL_MAP['copyout'])
502
503    parser_rm.add_argument('ARCH', help="D64 Image filename")
504    parser_rm.add_argument('DST', help="File inside D64 image to be deleted")
505    parser_rm.set_defaults(func=CALL_MAP['rm'])
506
507    args = parser.parse_args()
508    return args.func(args)
509
510
511def no_parse():
512    """
513    Failsafe argument "parsing". Note, that it blindly takes positional
514    arguments without checking them. In case of wrong arguments it will
515    silently exit
516    """
517    try:
518        if sys.argv[1] not in ('list', 'copyin', 'copyout', 'rm'):
519            sys.exit(2)
520    except IndexError:
521        sys.exit(2)
522
523    class Arg(object):
524        DST = None
525        SRC = None
526        ARCH = None
527
528    arg = Arg()
529
530    try:
531        arg.ARCH = sys.argv[2]
532        if sys.argv[1] in ('copyin', 'copyout'):
533            arg.SRC = sys.argv[3]
534            arg.DST = sys.argv[4]
535        elif sys.argv[1] == 'rm':
536            arg.DST = sys.argv[3]
537    except IndexError:
538        sys.exit(2)
539
540    CALL_MAP[sys.argv[1]](arg)
541
542if __name__ == "__main__":
543    LOG.debug("Script params: %s", str(sys.argv))
544    try:
545        from argparse import ArgumentParser
546        parse_func = parse_args
547    except ImportError:
548        parse_func = no_parse
549
550    parse_func()