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