#! /home/fli4l/fbr/fbr-4.0-trunk-x86_64/buildroot/output/host/usr/bin/python """ UC1541 Virtual filesystem This extfs provides an access to disk image files for the Commodore VIC20/C64/C128. It requires the utility c1541 that comes bundled with Vice, the emulator for the VIC20, C64, C128 and other computers made by Commodore. Remarks ------- Due to different way of representing file entries on regular D64 disk images, there could be issues with filenames that are transferred from/to the image. Following rules was applied to represent a single file entry: 1. An extension is attached to the end of a filename depending on a file type. Possible extensions are: prg, del, seq, usr and rel. 2. Every non-ASCII character (which could be some of characters specific to PET-ASCII, or be a control character) will be replaced by dot (.), since c1541 program will list them that way. 3. Every slash character (/) will be replaced by pipe character (|). 4. Leading space will be replaced by tilda (~). While copying from D64 image to filesystem, filenames will be stored as they are seen on a listing. While copying from filesystem to D64 image, filename conversion will be done: 1. Every $ and * characters will be replaced by question mark (?) 2. Every pipe (|) and backslash (\) characters will be replaced by slash (/) 3. Every tilda (~) will be replaced by a space 4. 'prg' extension will be truncated Representation of a directory can be sometimes confusing - in case when one copied file without extension it stays there in such form, till next access (after flushing VFS). Also file sizes are not accurate, since D64 directory entries have sizes stored as 256 bytes blocks. Configuration ------------- Here are specific for this script variable, which while set, can influence script behaviour: UC1541_DEBUG - if set, uc1541 will produce log in /tmp/uc1541.log file UC1541_VERBOSE - of set, script will be more verbose, i.e. error messages form c1541 program will be passed to Midnight Commander, so that user will be aware of error cause if any. UC1541_HIDE_DEL - if set, no DEL entries will be shown Changelog: 2.5 Fixed bug with filenames started with a '-' sign. 2.4 Fixed endless loop bug for reading directory in Python implemented directory reader. 2.3 Re added and missing method _correct_fname used for writing files into d64 image. 2.2 Fixed bug(?) with unusual sector end (marked as sector 0, not 255), causing endless directory reading on random locations. 2.1 Fixed bug with filenames containing slash. 2.0 Added reading raw D64 image, and mapping for jokers. Now it is possible to read files with PET-ASCII/control sequences in filenames. Working with d64 images only. Added workaround for space at the beginning of the filename. 1.2 Added configuration env variables: UC1541_VERBOSE and UC1541_HIDE_DEL. First one, if set to any value, will cause that error messages from c1541 program will be redirected as a failure messages visible in MC. The other variable, when set to any value, cause "del" entries to be not shown in the lister. 1.1 Added protect bits, added failsafe for argparse module 1.0 Initial release Author: Roman 'gryf' Dobosz Date: 2012-10-15 Version: 2.5 Licence: BSD """ import sys import re import os from subprocess import Popen, PIPE if os.getenv('UC1541_DEBUG'): import logging LOG = logging.getLogger('UC1541') LOG.setLevel(logging.DEBUG) FILE_HANDLER = logging.FileHandler("/tmp/uc1541.log") FILE_FORMATTER = logging.Formatter("%(asctime)s %(levelname)-8s " "%(lineno)s %(funcName)s - %(message)s") FILE_HANDLER.setFormatter(FILE_FORMATTER) FILE_HANDLER.setLevel(logging.DEBUG) LOG.addHandler(FILE_HANDLER) else: class LOG(object): """ Dummy logger object. does nothing. """ @classmethod def debug(*args, **kwargs): pass @classmethod def info(*args, **kwargs): pass @classmethod def warning(*args, **kwargs): pass @classmethod def error(*args, **kwargs): pass @classmethod def critical(*args, **kwargs): pass class D64(object): """ Implement d64 directory reader """ CHAR_MAP = {32: ' ', 33: '!', 34: '"', 35: '#', 37: '%', 38: '&', 39: "'", 40: '(', 41: ')', 42: '*', 43: '+', 44: ',', 45: '-', 46: '.', 47: '/', 48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5', 54: '6', 55: '7', 56: '8', 57: '9', 59: ';', 60: '<', 61: '=', 62: '>', 63: '?', 64: '@', 65: 'a', 66: 'b', 67: 'c', 68: 'd', 69: 'e', 70: 'f', 71: 'g', 72: 'h', 73: 'i', 74: 'j', 75: 'k', 76: 'l', 77: 'm', 78: 'n', 79: 'o', 80: 'p', 81: 'q', 82: 'r', 83: 's', 84: 't', 85: 'u', 86: 'v', 87: 'w', 88: 'x', 89: 'y', 90: 'z', 91: '[', 93: ']', 97: 'A', 98: 'B', 99: 'C', 100: 'D', 101: 'E', 102: 'F', 103: 'G', 104: 'H', 105: 'I', 106: 'J', 107: 'K', 108: 'L', 109: 'M', 110: 'N', 111: 'O', 112: 'P', 113: 'Q', 114: 'R', 115: 'S', 116: 'T', 117: 'U', 118: 'V', 119: 'W', 120: 'X', 121: 'Y', 122: 'Z', 193: 'A', 194: 'B', 195: 'C', 196: 'D', 197: 'E', 198: 'F', 199: 'G', 200: 'H', 201: 'I', 202: 'J', 203: 'K', 204: 'L', 205: 'M', 206: 'N', 207: 'O', 208: 'P', 209: 'Q', 210: 'R', 211: 'S', 212: 'T', 213: 'U', 214: 'V', 215: 'W', 216: 'X', 217: 'Y', 218: 'Z'} FILE_TYPES = {0b000: 'del', 0b001: 'seq', 0b010: 'prg', 0b011: 'usr', 0b100: 'rel'} def __init__(self, dimage): """ Init """ LOG.debug('image: %s', dimage) dimage = open(dimage, 'rb') self.raw = dimage.read() dimage.close() self.current_sector_data = None self.next_sector = 0 self.next_track = None self._dir_contents = [] self._already_done = [] def _map_filename(self, string): """ Transcode filename to ASCII compatible. Replace not supported characters with jokers. """ filename = list() for chr_ in string: if ord(chr_) == 160: # shift+space character; $a0 break character = D64.CHAR_MAP.get(ord(chr_), '?') filename.append(character) # special cases if filename[0] == "-": filename[0] = "?" LOG.debug("string: ``%s'' mapped to: ``%s''", string, "".join(filename)) return "".join(filename) def _go_to_next_sector(self): """ Fetch (if exist) next sector from a directory chain Return False if the chain ends, True otherwise """ # Well, self.next_sector _should_ have value $FF, but apparently there # are the cases where it is not, therefore checking for that will not # be performed and value of $00 on the next track will end the # directory if self.next_track == 0: LOG.debug("End of directory") return False if self.next_track is None: LOG.debug("Going to the track: 18,1") offset = self._get_d64_offset(18, 1) else: offset = self._get_d64_offset(self.next_track, self.next_sector) LOG.debug("Going to the track: %s,%s", self.next_track, self.next_sector) self.current_sector_data = self.raw[offset:offset + 256] self.next_track = ord(self.current_sector_data[0]) self.next_sector = ord(self.current_sector_data[1]) if (self.next_track, self.next_sector) in self._already_done: # Just a failsafe. Endless loop is not what is expected. LOG.debug("Loop in track/sector pointer at %d,%d", self.next_track, self.next_sector) self._already_done = [] return False self._already_done.append((self.next_track, self.next_sector)) LOG.debug("Next track: %s,%s", self.next_track, self.next_sector) return True def _get_ftype(self, num): """ Get filetype as a string """ return D64.FILE_TYPES.get(int("%d%d%d" % (num & 4 and 1, num & 2 and 1, num & 1), 2), '???') def _get_d64_offset(self, track, sector): """ Return offset (in bytes) for specified track and sector. """ offset = 0 truncate_track = 0 if track > 17: offset = 17 * 21 * 256 truncate_track = 17 if track > 24: offset += 6 * 19 * 256 truncate_track = 24 if track > 30: offset += 5 * 18 * 256 truncate_track = 30 track = track - truncate_track offset += track * sector * 256 return offset def _harvest_entries(self): """ Traverse through sectors and store entries in _dir_contents """ sector = self.current_sector_data for x in range(8): entry = sector[:32] ftype = ord(entry[2]) if ftype == 0: # deleted sector = sector[32:] continue type_verbose = self._get_ftype(ftype) protect = ord(entry[2]) & 64 and "<" or " " fname = entry[5:21] if ftype == 'rel': size = ord(entry[23]) else: size = ord(entry[30]) + ord(entry[31]) * 226 self._dir_contents.append({'fname': self._map_filename(fname), 'ftype': type_verbose, 'size': size, 'protect': protect}) sector = sector[32:] def list_dir(self): """ Return directory list as list of dict with keys: fname, ftype, protect and size """ while self._go_to_next_sector(): self._harvest_entries() return self._dir_contents class Uc1541(object): """ Class for interact with c1541 program and MC """ PRG = re.compile(r'(\d+)\s+"([^"]*)".+?\s(del|prg|rel|seq|usr)([\s<])') def __init__(self, archname): self.arch = archname self.out = '' self.err = '' self._verbose = os.getenv("UC1541_VERBOSE", False) self._hide_del = os.getenv("UC1541_HIDE_DEL", False) self.pyd64 = D64(archname).list_dir() self.file_map = {} self.directory = [] def list(self): """ Output list contents of D64 image. Convert filenames to be Unix filesystem friendly Add suffix to show user what kind of file do he dealing with. """ LOG.info("List contents of %s", self.arch) directory = self._get_dir() for entry in directory: sys.stdout.write("%(perms)s 1 %(uid)-8d %(gid)-8d %(size)8d " "Jan 01 1980 %(display_name)s\n" % entry) return 0 def rm(self, dst): """ Remove file from D64 image """ LOG.info("Removing file %s", dst) dst = self._get_masked_fname(dst) if not self._call_command('delete', dst=dst): return self._show_error() # During removing, a message containing ERRORCODE is sent to stdout # instead of stderr. Everything other than 'ERRORCODE 1' (which means: # 'everything fine') is actually a failure. In case of verbose error # output it is needed to copy self.out to self.err. if '\nERRORCODE 1\n' not in self.out: self.err = self.out return self._show_error() return 0 def copyin(self, dst, src): """ Copy file to the D64 image. Destination filename has to be corrected. """ LOG.info("Copy into D64 %s as %s", src, dst) dst = self._correct_fname(dst) if not self._call_command('write', src=src, dst=dst): return self._show_error() return 0 def copyout(self, src, dst): """ Copy file form the D64 image. Source filename has to be corrected, since it's representation differ from the real one inside D64 image. """ LOG.info("Copy form D64 %s as %s", src, dst) if not src.endswith(".prg"): return "cannot read" src = self._get_masked_fname(src) if not self._call_command('read', src=src, dst=dst): return self._show_error() return 0 def _correct_fname(self, fname): """ Return filename with mapped characters, without .prg extension. Characters like $, *, + in filenames are perfectly legal, but c1541 program seem to have issues with it while writing, so it will also be replaced. """ char_map = {'|': "/", "\\": "/", "~": " ", "$": "?", "*": "?"} if fname.lower().endswith(".prg"): fname = fname[:-4] new_fname = [] for char in fname: trans = char_map.get(char) new_fname.append(trans if trans else char) return "".join(new_fname) def _get_masked_fname(self, fname): """ Return masked filename with '?' jokers instead of non ASCII characters, useful for copying or deleting files with c1541. In case of several files with same name exists in directory, only first one will be operative (first as appeared in directory). Warning! If there are two different names but the only difference is in non-ASCII characters (some PET ASCII or control characters) there is a risk that one can remove both files. """ directory = self._get_dir() for entry in directory: if entry['display_name'] == fname: return entry['pattern_name'] def _get_dir(self): """ Retrieve directory via c1541 program """ directory = [] uid = os.getuid() gid = os.getgid() if not self._call_command('list'): return self._show_error() idx = 0 for line in self.out.split("\n"): if Uc1541.PRG.match(line): blocks, fname, ext, rw = Uc1541.PRG.match(line).groups() if ext == 'del' and self._hide_del: continue display_name = ".".join([fname, ext]) pattern_name = self.pyd64[idx]['fname'] if '/' in display_name: display_name = display_name.replace('/', '|') # workaround for space and dash at the beginning of the # filename char_map = {' ': '~', '-': '_'} display_name = "".join([char_map.get(display_name[0], display_name[0]), display_name[1:]]) if ext == 'del': perms = "----------" else: perms = "-r%s-r--r--" % (rw.strip() and "-" or "w") directory.append({'pattern_name': pattern_name, 'display_name': display_name, 'uid': uid, 'gid': gid, 'size': int(blocks) * 256, 'perms': perms}) idx += 1 return directory def _show_error(self): """ Pass out error output from c1541 execution """ if self._verbose: sys.exit(self.err) else: sys.exit(1) def _call_command(self, cmd, src=None, dst=None): """ Return status of the provided command, which can be one of: write read delete dir/list """ command = ['c1541', '-attach', self.arch, '-%s' % cmd] if src and dst: command.append(src) command.append(dst) elif src or dst: command.append(src and src or dst) self.out, self.err = Popen(command, stdout=PIPE, stderr=PIPE).communicate() return not self.err CALL_MAP = {'list': lambda a: Uc1541(a.ARCH).list(), 'copyin': lambda a: Uc1541(a.ARCH).copyin(a.SRC, a.DST), 'copyout': lambda a: Uc1541(a.ARCH).copyout(a.SRC, a.DST), 'rm': lambda a: Uc1541(a.ARCH).rm(a.DST)} def parse_args(): """ Use ArgumentParser to check for script arguments and execute. """ parser = ArgumentParser() subparsers = parser.add_subparsers(help='supported commands') parser_list = subparsers.add_parser('list', help="List contents of D64 " "image") parser_copyin = subparsers.add_parser('copyin', help="Copy file into D64 " "image") parser_copyout = subparsers.add_parser('copyout', help="Copy file out of " "D64 image") parser_rm = subparsers.add_parser('rm', help="Delete file from D64 image") parser_list.add_argument('ARCH', help="D64 Image filename") parser_list.set_defaults(func=CALL_MAP['list']) parser_copyin.add_argument('ARCH', help="D64 Image filename") parser_copyin.add_argument('SRC', help="Source filename") parser_copyin.add_argument('DST', help="Destination filename (to be " "written into D64 image)") parser_copyin.set_defaults(func=CALL_MAP['copyin']) parser_copyout.add_argument('ARCH', help="D64 Image filename") parser_copyout.add_argument('SRC', help="Source filename (to be read from" " D64 image") parser_copyout.add_argument('DST', help="Destination filename") parser_copyout.set_defaults(func=CALL_MAP['copyout']) parser_rm.add_argument('ARCH', help="D64 Image filename") parser_rm.add_argument('DST', help="File inside D64 image to be deleted") parser_rm.set_defaults(func=CALL_MAP['rm']) args = parser.parse_args() return args.func(args) def no_parse(): """ Failsafe argument "parsing". Note, that it blindly takes positional arguments without checking them. In case of wrong arguments it will silently exit """ try: if sys.argv[1] not in ('list', 'copyin', 'copyout', 'rm'): sys.exit(2) except IndexError: sys.exit(2) class Arg(object): DST = None SRC = None ARCH = None arg = Arg() try: arg.ARCH = sys.argv[2] if sys.argv[1] in ('copyin', 'copyout'): arg.SRC = sys.argv[3] arg.DST = sys.argv[4] elif sys.argv[1] == 'rm': arg.DST = sys.argv[3] except IndexError: sys.exit(2) CALL_MAP[sys.argv[1]](arg) if __name__ == "__main__": LOG.debug("Script params: %s", str(sys.argv)) try: from argparse import ArgumentParser parse_func = parse_args except ImportError: parse_func = no_parse parse_func()