From: norly Date: Sun, 16 Dec 2018 22:05:33 +0000 (+0100) Subject: Import first working version X-Git-Url: https://git.enpas.org/?p=fuse-aexplorer.git;a=commitdiff_plain;h=f06df88629c6ae1205f522148102a0c3d04d1cf2 Import first working version --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15584ca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.pyc diff --git a/AEpy/AECmds.py b/AEpy/AECmds.py new file mode 100644 index 0000000..a9ccd99 --- /dev/null +++ b/AEpy/AECmds.py @@ -0,0 +1,321 @@ +import binascii +import serial +from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP, S_IROTH, S_IXOTH, S_IFDIR, S_IFREG +import struct + +from AESession import AESession +import AEMultipart + + +################################### +# +# All paths are without leading / +# All paths are in Amiga charset. +# +################################### + + +# Command 0x01 +# Cancel a running operation. +# +def cancel(session): + session.sendMsg(1, b'') + + + +# Command 0x64 +# List a directory. +# +def dir_list(session, path): + print("AECmds.dir_list: " + path) + + session.sendMsg(0x64, path + b'\x00\x01') + + dirlist = AEMultipart.recv(session) + # TODO: Check for None returned, meaning that the path doesn't exist. + # Actually, what can we return then? + # None is not iterable, and [] indicates an empty directory. + close(session) + + + # Parse dirlist + + (numents,) = struct.unpack('!L', dirlist[0:4]) + dirlist = dirlist[4:] + + kids = {} + #kids['.'] = (dict(st_mode=(S_IFDIR | 0o755), st_nlink=2), {}) + #kids['..'] = (dict(st_mode=(S_IFDIR | 0o755), st_nlink=2), {}) + + while numents > 0: + assert len(dirlist) > 29 + (entlen, size, used, type, attrs, mdays, mmins, mticks, type2) = struct.unpack('!LLLHHLLLB', dirlist[0:29]) + # entlen - Size of this entry in the list, in bytes + # size - Size of file. + # For drives, total space. + # used - 0 for files and folders. + # For drives, space used. + # type - ? + # attrs - Amiga attributes + # mdays - Last modification in days since 1978-01-01 + # mmins - Last modification in minutes since mdays + # mticks - Last modification in ticks (1/50 sec) since mmins + # type2 - 0: Volume + # - 1: Volume + # - 2: Directory + # - 3: File + # - 4: ROM + # - 5: ADF + # - 6: HDF + assert entlen <= len(dirlist) + + name = dirlist[29:entlen].split(b'\x00')[0].decode(encoding="iso8859-1") + + dirlist = dirlist[entlen:] + numents -= 1 + + st = {} + st = {} + st['st_mode'] = 0 + #if attrs & 0x40: st['st_mode'] |= Script + #if attrs & 0x20: st['st_mode'] |= Pure + #if attrs & 0x10: st['st_mode'] |= Archive + if not attrs & 0x08: st['st_mode'] |= S_IRUSR | S_IRGRP | S_IROTH # Read + if not attrs & 0x04: st['st_mode'] |= S_IWUSR # Write + if not attrs & 0x02: st['st_mode'] |= S_IXUSR | S_IXGRP | S_IXOTH # Execute + #if not attrs & 0x01: st['st_mode'] |= Delete + + # Check for directory + if type & 0x8000: + st['st_mode'] |= S_IFDIR + else: + st['st_mode'] |= S_IFREG + st['st_nlink'] = 1 + + st['st_size'] = size + + st['st_mtime'] = (2922*24*60*60) # Amiga epoch starts 1978-01-01 + st['st_mtime'] += (mdays*24*60*60) # Days since 1978-01-01 + st['st_mtime'] += (mmins*60) # Minutes + st['st_mtime'] += (mticks/50) # Ticks of 1/50 sec + + st['st_blksize'] = session.packetsize - 4 + + # TODO: Convert time zone. + # Amiga time seems to be local time, we currently treat it as UTC. + + kids[name] = (st, None) + + return kids + + + +# Command 0x65 +# Read a file. +# +def file_read_start(session, path): + print("AECmds.file_read_start: " + path) + + # Request file + session.sendMsg(0x65, path + b'\x00') + + # Get file response header + (filelen, pktsize) = AEMultipart.recvHeader(session) + + # TODO: Check for None returned, meaning that the path doesn't exist. + # This may not be necessary in case FUSE always issues a getattr() + # beforehand. + + return filelen + + +def file_read_next(session): + (blockOffset, blockData) = AEMultipart.recvBlock(session) + + if (blockData == None): + blen = None + else: + blen = len(blockData) + + print("AECmds.file_read_next: " + str(blen) + ' @ ' + str(blockOffset)) + + return (blockOffset, blockData) + + + +# Command 0x66 +# Write a complete file and set its attributes and time. +# +def file_write(session, path, amigaattrs, unixtime, filebody): + print('AECmds.file_write: ' + path + ' -- ' + str(len(filebody))) + + filelen = len(filebody) + + _utime = unixtime - (2922*24*60*60) # Amiga epoch starts 1978-01-01 + mdays = int(_utime / (24*60*60)) # Days since 1978-01-01 + _utime -= mdays * (24*60*60) + mmins = int(_utime / 60) # Minutes + _utime -= mmins * 60 + mticks = _utime * 50 # Ticks of 1/50 sec + + filetype = 3 # Regular file + + data = struct.pack('!LLLLLLLB', + 29 + len(path) + 6, # Length of this message, really + filelen, + 0, + amigaattrs, + mdays, + mmins, + mticks, + filetype) + data += path + data += b'\0\0\0\0\0\0' # No idea what this is for + session.sendMsg(0x66, data) + + # TODO: Handle target FS full + # TODO: Handle permission denied on target FS + + AEMultipart.send(session, filebody) + close(session) + + + +# Command 0x67 +# Delete a path. +# Can be a file or folder, and will be deleted recursively. +# +def delete(session, path): + print("AECmds.delete: " + path) + + session.sendMsg(0x67, path + b'\x00') + + (type, _) = session.recvMsg() + + close(session) + + # type == 0 means the file was deleted successfully + return (type == 0) + + + +# Command 0x68 +# Rename a file, leaving it in the same folder. +# +def rename(session, path, new_name): + print("AECmds.rename: " + path + ' - ' + new_name) + session.sendMsg(0x68, path + b'\x00' + new_name + b'\x00') + (type, _) = session.recvMsg() + + if type != 0: + print("AECmds.rename: Response type " + str(type)) + + close(session) + + # Assume that type == 0 means the file was renamed successfully + return (type == 0) + + + +# Command 0x69 +# Move a file from a folder to a different one, keeping its name. +# +def move(session, path, new_parent): + print("AECmds.move: " + path + ' - ' + new_parent) + session.sendMsg(0x69, path + b'\x00' + new_parent + b'\x00\xc9') + (type, _) = session.recvMsg() + + if type != 0: + print("AECmds.move: Response type " + str(type)) + + close(session) + + # Assume that type == 0 means the file was moved successfully + return (type == 0) + + + +# Command 0x6a +# Copy a file. +# +def copy(session, path, new_parent): + print("AECmds.copy: " + path + ' - ' + new_parent) + session.sendMsg(0x6a, path + b'\x00' + new_parent + b'\x00\xc9') + (type, _) = session.recvMsg() + + if type != 0: + print("AECmds.copy: Response type " + str(type)) + + close(session) + + # Assume that type == 0 means the file was copied successfully + return (type == 0) + + + +# Command 0x6b +# Set attributes and comment. +# +def setattr(session, amigaattrs, path, comment): + print("AECmds.setattr: " + str(amigaattrs) + ' - ' + path + ' - ' + comment) + + data = struct.pack('!L', amigaattrs) + data += path + b'\x00' + data += comment + b'\x00' + data += struct.pack('!L', 0) + + session.sendMsg(0x6b, data) + (type, _) = session.recvMsg() + + if type != 0: + print("AECmds.setattr: Response type " + str(type)) + + close(session) + + # Assume that type == 0 means the file was copied successfully + return (type == 0) + + + +# Command 0x6c +# Request (?) +# + + + +# Command 0x6d +# Finish a running operation. +# +def close(session): + print('AECmds.close') + + session.sendMsg(0x6d, b'') + + (type, data) = session.recvMsg() + assert type == 0x0a + if data != b'\0\0\0\0\0': + print('AECmds.close: data == ' + binascii.hexlify(data)) + # Format of data returned: + # Byte 0: ? + # Byte 1: ? + # Byte 2: ? + # Byte 3: 00 - No error + # 06 - File no longer exists + # 1c - Timed out waiting for host to request next read block + # Byte 4: Path in case of error 06. + # Empty (null-terminated) otherwise? + # Null-terminated string. + #assert data == b'\0\0\0\0\0' + + + +# Command 0x6e +# Format a disk. +# + + + +# Command 0x6f +# Create a new folder and matching .info file. +# This folder will be named "Unnamed1". +# diff --git a/AEpy/AEFuse.py b/AEpy/AEFuse.py new file mode 100644 index 0000000..fea6037 --- /dev/null +++ b/AEpy/AEFuse.py @@ -0,0 +1,459 @@ +import binascii +from errno import ENOENT, EINVAL, EIO, EROFS +from fusepy import FUSE, FuseOSError, Operations +from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IROTH, S_IXGRP, S_IXOTH, S_IFDIR, S_IFREG +import struct +import sys +from time import time + +from AELink import AELinkSerial +from AESession import AESession +import AEMultipart +import AECmds +from FuseCache import FuseCache +import FusePatches + + +amiga_charset = 'iso8859-1' + + + + + + + + +class AEFuse(Operations): + def __init__(self, session): + self.session = session + + self.cache = FuseCache() + + self.readpath = None + self.readbuf = None + self.readmax = None # Total size of the file being read + + self.writepath = None + self.writebuf = None + self.writemtime = None + self.writeattrs = None + + + def getattr(self, path, fh=None): + print('AEFuse.getattr: ' + path) + + dirpath = path[0:path.rfind('/')] # Without the trailing slash + name = path[path.rfind('/') + 1:] # Without a leading slash + + st = self.cache.getattr(path) + + if not st: + # If we have a cache miss, see if we've cached its parent + # directory. + # + # If not, then try to read the parent directory. + # + # Note that accessing a deep path will cause Linux to stat its + # parents recursively, which in turn will cause self.getattr() + # to call self.readdir() on them, filling all cache levels in + # between. + # + # https://sourceforge.net/p/fuse/mailman/message/19717106/ + # + if self.cache.getkids(dirpath) == None: + # We haven't read the directory yet + if name in self.readdir(dirpath, fh): + # If the dirlist succeeded, try again. + # + # Note that we're never asking the host for a deep path + # that doesn't exist, since Linux will stat all parents, + # thus listing directories at every level until the first + # one that doesn't exist (see above). + # + # As a bonus, this automatically updates the cache if the + # path now exists. + return self.getattr(path, fh) + + # Okay, the path doesn't exist. + # That means our file can't exist. + print('getaddr ENOENT: ' + path) + raise FuseOSError(ENOENT) + + return st + + + def readdir(self, path, fh): + print("AEFuse.readdir: " + path) + + self._cancel_read() + self._flush_write() + + # Request directory listing + kids = AECmds.dir_list(self.session, path[1:].encode(amiga_charset)) + + # Update cache + self.cache.setkids(path, kids) + + #return [(name, self.cache[name], 0) for name in self.cache.keys()] + return kids.keys() + + + def read(self, path, size, offset, fh): + print("AEFuse.read: " + path + ' -- ' + str(size) + ' @ ' + str(offset)) + + # Avoid inconsistency. + # If we don't do this, then reading the file while it's open + # returns something different from what is in the write buffer. + self._flush_write() + + if self.readpath != path: + if self.readpath != None: + self._cancel_read() + + self.readpath = path + self.readbuf = b'' + + # Request file + self.readmax = AECmds.file_read_start(self.session, path[1:].encode(amiga_charset)) + + # TODO: Check for None returned, meaning that the path doesn't exist. + + if (offset + size > len(self.readbuf)) \ + and (offset < self.readmax) \ + and (len(self.readbuf) < self.readmax): + # Get further file contents if we need it. + while len(self.readbuf) < min(offset + size, self.readmax): + (blockOffset, blockData) = AECmds.file_read_next(self.session) + + if blockData == None: + # Since we only enter this loop if there should be data + # left to read, a None returned means an unexpected EOF + # or more probably, the connection timed out. + # + # We operate under the illusion that we can keep the last + # accessed file open wherever we were at, however after a + # few seconds the host will close the file without us + # knowing. In case that happens, let's reload the file. + print('Unexpected error while reading next file block.') + print('Re-requesting file.') + + # If we cancel the transfer ourselves, then the reply to + # sendClose() will be 0x00 0x00 0x00 0x1c 0x00. + # + # Strange: If we cancel in the same situation, but without + # having received the type 1 cancel message from the host, + # the reply to sendClose() is normal zeroes. + self._cancel_read() + return self.read(path, size, offset, fh) + + assert blockOffset == len(self.readbuf) + self.readbuf += blockData + + # When done transferring a whole file, the Amiga side insists on + # sending its end-of-file type 4 message. So let's request it. + if len(self.readbuf) == self.readmax: + (blockOffset, blockData) = AECmds.file_read_next(self.session) + assert blockData == None + + print("read: " + path + ' -- finished with len(self.readbuf) == ' + str(len(self.readbuf))) + return self.readbuf[offset:offset + size] + + + def _cancel_read(self): + # Cancel current read operation + if self.readpath != None: + if (len(self.readbuf) < self.readmax): + print('_cancel_read: Cancelling.') + AECmds.cancel(self.session) + + AECmds.close(self.session) + + self.readbuf = None + self.readpath = None + + + def open(self, path, flags): + print("AEFuse.open: " + path) + + # Dummy function to allow file access. + + return 0 + + + def _prep_write(self, path, trunclen): + # We can't write in the virtual top level directory + if len(path) < 4: + raise FuseOSError(ENOENT) + + self._cancel_read() + + if self.writepath != path: + self._flush_write() + + # If the file already exists, read its previous contents. + # Check cache, since FUSE/VFS will have filled it. + st = self.cache.getattr(path) + if st != None and st['st_size'] > 0: + if trunclen != None: + if trunclen > 0: + self.writebuf = self.read(path, trunclen, 0, None) + else: + self.writebuf = self.read(path, st['st_size'], 0, None) + self._cancel_read() + + self.writepath = path + self.writeattrs = 0 + if self.writebuf == None: + self.writebuf = b'' + + + def create(self, path, mode, fi=None): + print("AEFuse.create: " + path) + + # We can't write in the virtual top level directory + if len(path) < 4: + raise FuseOSError(ENOENT) + + # We can't create a file over an existing one. + # By now, the system will have primed our cache, so we won't ask + # the big fuseop self.getattr(). That wouldn't work anyway, since + # it raises a FuseOSError rather than returning None. + assert self.cache.getattr(path) == None + if self.writepath != None: + assert path != self.writepath + + # Create a dummy file, so the path exists + AECmds.file_write(self.session, path[1:].encode(amiga_charset), 0, time(), b'') + + # Refresh cache so a subsequent getattr() succeeds + dirpath = path[0:path.rfind('/')] + self.readdir(dirpath, None) + + return 0 + + + def truncate(self, path, length, fh=None): + print("AEFuse.truncate: " + path + ' -- ' + str(length)) + + self._prep_write(path, length) + + self.writebuf = self.writebuf[:length] + if length > len(self.writebuf): + # Fill up + self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (length - len(self.writebuf))) + + self.writemtime = time() + + return 0 + + + + # WARNING: Due to buffering, we will NOT be able to report + # a full disk as a response to the write call! + def write(self, path, data, offset, fh): + print("AEFuse.write: " + path + ' -- ' + str(len(data)) + ' @ ' + str(offset)) + + self._prep_write(path, None) + + if offset > len(self.writebuf): + # Fill up + self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (offset - len(self.writebuf))) + + self.writebuf = self.writebuf[:offset] + data + self.writebuf[offset+len(data):] + self.writemtime = time() + + # WARNING! + # We cannot check for a full disk here, so we're + # just assuming that buffering is fine. + return len(data) + + + def utimens(self, path, (atime, mtime)): + print("AEFuse.utimens: " + path + ' -- ' + str(mtime)) + + # We can only set the time when sending a complete file. + if self.writepath == path: + self.writemtime = mtime + else: + raise FuseOSError(EROFS) + + + def _flush_write(self): + if self.writepath == None: + return + + # If this fails, there is nothing we can do. + # Except returning an error on close(). + # But honestly, who checks for that? + AECmds.file_write(self.session, + self.writepath[1:].encode(amiga_charset), self.writeattrs, + self.writemtime, self.writebuf) + + # Extract dirpath before we throw away self.writepath + dirpath = self.writepath[0:self.writepath.rfind('/')] + + # Throw away the write buffer once done, so we don't cache full files. + self.writepath = None + self.writebuf = None + self.writemtime = None + self.writeattrs = None + + # Refresh cache so subsequent accesses are correct + self.readdir(dirpath, None) + + + # Called on close() + def flush(self, path, fh): + print("AEFuse.flush: " + path) + + # Flush any remaining write buffer + self._flush_write() + + print("AEFuse.flush: finished: " + path) + + + def unlink(self, path): + print("AEFuse.unlink: " + path) + + # TODO: Handle file that is currently open for reading/writing + + self.session.sendMsg(0x67, path[1:].encode(amiga_charset) + b'\x00') + + (type, _) = self.session.recvMsg() + + self.session.sendClose() + + # Refresh cache so a subsequent getattr() is up to date. + # We refresh even in case the delete failed, because it + # indicates our cache being out of date. + dirpath = path[0:path.rfind('/')] + self.readdir(dirpath, None) + + if type == 0: + return 0 # File deleted successfully + else: + raise FuseOSError(EIO) + + + def rmdir(self, path): + # TODO: Refuse if the directory is not empty + return self.unlink(path) + + + def ae_mkdir(self, path): + self._cancel_read() + + amigaattrs = 0 + + _utime = time() - (2922*24*60*60) # Amiga epoch starts 1978-01-01 + mdays = int(_utime / (24*60*60)) # Days since 1978-01-01 + _utime -= mdays * (24*60*60) + mmins = int(_utime / 60) # Minutes + _utime -= mmins * 60 + mticks = _utime * 50 # Ticks of 1/50 sec + + filetype = 2 # Folder + + data = struct.pack('!LLLLLLLB', + 29 + len(path) + 6, # Length of this message, really + 0, + 0, + amigaattrs, + mdays, + mmins, + mticks, + filetype) + data += path.encode(amiga_charset) + data += b'\0\0\0\0\0\0' # No idea what this is for + self.session.sendMsg(0x66, data) + + # TODO: Handle target FS full + # TODO: Handle permission denied on target FS + + self.session.sendClose() + + + def mkdir(self, path, mode): + print('AEFuse.mkdir: ' + path) + + self.ae_mkdir(path[1:].encode(amiga_charset)) + + # Refresh cache so a subsequent getattr() succeeds + dirpath = path[0:path.rfind('/')] + self.readdir(dirpath, None) + + + def rename(self, old, new): + print('AEFuse.rename: ' + old + ' - ' + new) + dirpath_old = old[0:old.rfind('/')] + dirpath_new = new[0:new.rfind('/')] + filename_old = old[old.rfind('/') + 1:] + filename_new = new[new.rfind('/') + 1:] + + # If the file already exists, delete it. + # Check cache, since FUSE/VFS will have filled it. + st = self.cache.getattr(new) + if st != None: + self.unlink(new) + + if dirpath_new == dirpath_old: + AECmds.rename(self.session, + old[1:].encode(amiga_charset), + filename_new.encode(amiga_charset)) + else: + # Move in 3 steps: + # 1. Rename file to a temporary name + # (NOTE: We hope it doesn't exist!) + # 2. Move the file to the new folder + # 3. Rename the file to the target file name + # The reason for this is that the old file name may exist in + # the new directory, and the new file name may exist in the + # old directory. Thus, if we were to do only one renaming, + # either order of rename+move or move+rename could be + # problematic. + + assert (self.cache.getattr(dirpath_old + '/_fatemp_') == None) + assert (self.cache.getattr(dirpath_new + '/_fatemp_') == None) + + AECmds.rename(self.session, + old[1:].encode(amiga_charset), + '_fatemp_'.encode(amiga_charset)) + AECmds.move(self.session, + (dirpath_old + '/_fatemp_')[1:].encode(amiga_charset), + dirpath_new[1:].encode(amiga_charset)) + AECmds.rename(self.session, + (dirpath_new + '/_fatemp_')[1:].encode(amiga_charset), + filename_new.encode(amiga_charset)) + + # Refresh cache so a subsequent getattr() succeeds + self.readdir(dirpath_old, None) + self.readdir(dirpath_new, None) + + + def chmod(self, path, mode): + print("AEFuse.chmod: " + path + ' -- ' + str(mode)) + + amigaattrs = 0 + # Apparently we don't have to worry about directory flags + if not mode & S_IRUSR: amigaattrs |= 0x08 + if not mode & S_IWUSR: amigaattrs |= 0x04 + if not mode & S_IXUSR: amigaattrs |= 0x02 + + AECmds.setattr(self.session, + amigaattrs, + path[1:].encode(amiga_charset), + '') + + + def chown(self, path, uid, gid): + # AmigaOS does not know users/groups. + raise FuseOSError(EROFS) + + + # Called on umount + def destroy(self, path): + print('AEFuse.destroy: ' + path) + + # Flush any remaining write buffer + self._cancel_read() + self._flush_write() diff --git a/AEpy/AELink.py b/AEpy/AELink.py new file mode 100644 index 0000000..e75f3fb --- /dev/null +++ b/AEpy/AELink.py @@ -0,0 +1,36 @@ +import binascii +import serial +import struct +import traceback + + +class AELink: + def __init__(self): + return + + + + + +class AELinkSerial(AELink): + def __init__(self, path, speed): + self.ser = serial.Serial(path, speed, timeout=5, rtscts=1) + assert self.ser.name == path + + AELink.__init__(self) + + def recv(self, rxlen): + assert self.ser.is_open + buf = self.ser.read(rxlen) + #print("Received: " + binascii.hexlify(buf)) + return buf + + def send(self, data): + assert self.ser.is_open + #print("Sending: " + binascii.hexlify(data)) + #traceback.print_stack() + return self.ser.write(data) + + def close(self): + assert self.ser.is_open + self.ser.close() diff --git a/AEpy/AEMultipart.py b/AEpy/AEMultipart.py new file mode 100644 index 0000000..a542bfa --- /dev/null +++ b/AEpy/AEMultipart.py @@ -0,0 +1,94 @@ +import struct + + + +def recvHeader(session): + (type, data) = session.recvMsg() + # TODO: Check for type 1, as that means an error. + # For example, a directory can't be listed because it doesn't exist. + assert type == 3 + assert len(data) == 8 + + (datalen, pktsize) = struct.unpack('!LL', data) + + return (datalen, pktsize) + + +def recvBlock(session): + # Send empty type 0 message to request next block + session.sendMsg(0, b'') + + (type, data) = session.recvMsg() + + if type == 1: + print('recvMultipartBlock: Host cancelled transfer.') + # Maybe TODO: Raise an exception + return (None, None) + + if type == 4: + # Last block + assert len(data) == 0 + return (None, None) + + # Expect a data block + assert type == 5 + + # Any data block has the offset prepended, + # followed by up to PACKETSIZE-4 bytes of payload. + assert len(data) >= 4 + + (offset,) = struct.unpack('!L', data[0:4]) + + return (offset, data[4:]) + + +def recv(session): + fullData = b'' + + (datalen, pktsize) = recvHeader(session) + + offset = 0 + data = b'' + while data != None: + assert offset == len(fullData) + fullData += data + + (offset, data) = recvBlock(session) + + return fullData + + +def send(session, fullData): + # Wait for Amiga to request first block with type 0 + (type, _) = session.recvMsg() + + # TODO: Handle transfer abort (type 1) + # TODO: What happens when sending an ADF != 901120 bytes? + assert (type == 0) or (type == 8) + + if type == 8: + # Does this confirm that we want to overwrite? + session.sendMsg(0, b'') + + # Send length and block size + session.sendMsg(3, struct.pack('!LL', len(fullData), session.packetsize)) + + bytesSent = 0 + while bytesSent < len(fullData): + # Wait for Amiga to request block with type 0 + (type, _) = session.recvMsg() + assert type == 0 + + print("AEMultipart.send: " + str(bytesSent)) + + txlen = min(session.packetsize - 4, len(fullData) - bytesSent) + session.sendMsg(5, struct.pack('!L', bytesSent) + fullData[bytesSent : bytesSent+txlen]) + + bytesSent += txlen + + # Wait for Amiga's type 0 + (type, _) = session.recvMsg() + assert type == 0 + + # Finish transfer + session.sendMsg(4, b'') diff --git a/AEpy/AESession.py b/AEpy/AESession.py new file mode 100644 index 0000000..145fff7 --- /dev/null +++ b/AEpy/AESession.py @@ -0,0 +1,96 @@ +import binascii +import serial +import struct + + +class AESession: + def __init__(self, link): + self.link = link + self.seq = 1 + self.sendInit() + self.packetsize = 512 + + + def recvMsg(self): + rawHeader = self.link.recv(12) + if len(rawHeader) != 12: + print('recvMsg: len(rawHeader) == ' + str(len(rawHeader)) + ' data: ' + binascii.hexlify(rawHeader)) + #assert len(rawHeader) == 12 + + (type, datalen, seq, crc) = struct.unpack('!HHLL', rawHeader) + assert crc == (binascii.crc32(rawHeader[0:8]) & 0xffffffff) + # TODO: Send PkRs if CRC mismatches + + data = b'' + + if datalen > 0: + data = self.link.recv(datalen) + assert len(data) == datalen + + # Note: Python calculates signed CRCs. + # Thus, we parse the incoming CRC as signed. + datacrc = self.link.recv(4) + assert len(datacrc) == 4 + (datacrc,) = struct.unpack('!L', datacrc) + + assert datacrc == (binascii.crc32(data) & 0xffffffff) + # TODO: Send PkRs if CRC mismatches + + self.link.send(b'PkOk') + + # seq is currently ignored when receiving + return (type, data) + + + def sendMsg(self, type, data): + stream = struct.pack('!HHL', type, len(data), self.seq) + stream += struct.pack('!L', binascii.crc32(stream) & 0xffffffff) + + if len(data) > 0: + stream += data + stream += struct.pack('!L', binascii.crc32(data) & 0xffffffff) + + self.link.send(stream) + + # TODO: Re-send on 'PkRs' + assert self.link.recv(4) == b'PkOk' + + self.seq += 1; + + + + def sendInit(self): + self.sendMsg(2, b'Cloanto(r)') + # TBD: Password support. + # It is CRC32'd and the CRC is appended to the string above. + + # TODO: Recover from desynced state + #if not self.isAcked(): + # self.sendAck() + # return self.sendInit() + + (type, data) = self.recvMsg() + assert type == 2 + assert data.startswith(b'Cloanto') + + + def sendClose(self): + print('sendClose') + + self.sendMsg(0x6d, b'') + + (type, data) = self.recvMsg() + assert type == 0x0a + if data != b'\0\0\0\0\0': + print('sendClose: data == ' + binascii.hexlify(data)) + # Format of data returned: + # Byte 0: ? + # Byte 1: ? + # Byte 2: ? + # Byte 3: 00 - No error + # 06 - File no longer exists + # 1c - Timed out waiting for host to request next read block + # Byte 4: Path in case of error 06. + # Empty (null-terminated) otherwise? + # Null-terminated string. + #assert data == b'\0\0\0\0\0' diff --git a/AEpy/FuseCache.py b/AEpy/FuseCache.py new file mode 100644 index 0000000..420b992 --- /dev/null +++ b/AEpy/FuseCache.py @@ -0,0 +1,66 @@ +from stat import S_IFDIR + + +class FuseCache(): + def __init__(self): + # Initialize virtual root + self.cache = {} + + # Cache tuple: + # (dict of attrs, dict of children in folder) + + + def getattr(self, path): + paths = [p for p in path.split('/') if len(p) > 0] + #print(paths) + + # Virtual root + st = dict(st_mode=(S_IFDIR | 0o755), st_nlink=2) + + c = self.cache + for p in paths: + if c == None: + return None + + if p not in c: + return None + + (st, c) = c[p] + + return st + + + def getkids(self, path): + paths = [p for p in path.split('/') if len(p) > 0] + + c = self.cache + for p in paths: + if c == None: + return None + + if p not in c: + return None + + (_, c) = c[p] + + return c + + + # Set a cache node's children and their attributes. + # This implicitly purges any prvious children from the cache. + # Thus, those directories will be re-scanned next time. + def setkids(self, path, children): + paths = [p for p in path.split('/') if len(p) > 0] + + c = self.cache + for p in paths: + # We expect to be able to walk the path, because we can't get + # there without Linux' VFS stat'ing all directories leading up. + parent = c + (_, c) = c[p] + + if (c == self.cache): + self.cache = children + else: + (st, _) = parent[p] + parent[p] = (st, children) diff --git a/AEpy/FusePatches.py b/AEpy/FusePatches.py new file mode 100644 index 0000000..f02ddd2 --- /dev/null +++ b/AEpy/FusePatches.py @@ -0,0 +1,53 @@ +# Ugly patches for fusepy to accommodate inflexible userspace applications. +# + +from ctypes import c_uint, cast, POINTER, Structure +from fusepy import FUSE + + +class fuse_conn_info(Structure): + _fields_ = [ + ('proto_major', c_uint), + ('proto_minor', c_uint), + ('async_read', c_uint), + ('max_write', c_uint), + ('max_readahead', c_uint), + ('capable', c_uint), + ('want', c_uint), + ('max_background', c_uint), + ('congestion_threshold', c_uint) + ] + + +# Ugly patch for fusepy to allow changing max_readahead. +# +# This is necessary for FUSE filesystems which need to avoid unnecessary +# data transfers. +# +# Example: +# If a GIO (as in GLib/GNOME's GIO) based file manager (e.g. XFCE's Thunar) +# tries to determine each file's type by inspecting its header (GLib 2.50 +# reads up to 4 KB of each file), this results in a lot of traffic. If the +# kernel increases this to 16 KB as part of its buffered I/O's readahead, +# things become very slow on exotically slow links or host media. +# In fact, this patch has been written with a 2 KByte/s link in mind. +# +# Why not turn off this header-sniffing feature in userspace? +# Because... Neither Thunar nor GLib/GIO have this option, and exotic FUSE +# filesystems still need to work on them until they have a mechanism to turn +# this off. Preferably with a hint in stat() or statfs(). +# +# NOTE: +# The kernel will impose a lower bound on max_readahead. +# As of 4.9 on x86-64, this is 4096 bytes. +# (still a lot on a 2 KB/s link, but Thunar won't be *completely* useless) +# +def patch_max_readahead(max_readahead): + old_init = FUSE.init + + def _new_fuse_init(self, conn): + conn2 = cast(conn, POINTER(fuse_conn_info)) + conn2.contents.max_readahead = max_readahead + return old_init(self, conn) + + FUSE.init = _new_fuse_init diff --git a/AEpy/__init__.py b/AEpy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/COPYING @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6775bd --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +fuse-aexplorer +=============== + +A crude re-implementation of the Amiga Explorer protocol, for Linux/FUSE. + +All knowledge of the protocol is either based on Mark Street's lxamiga or +[lxamiga.pl](https://github.com/marksmanuk/lxamiga) or gained by +monitoring communication between the original Windows version of +Amiga Explorer and the Amiga side. + +This software is highly experimental and use is fully AT YOUR OWN RISK. +Please don't blame me or Cloanto or anyone if anything breaks. + +If you experience any issues (and that's almost guaranteed), please use +[Cloanto's official Windows software](https://www.amigaforever.com/ae/) +instead. It's a great, stable software package. + + +Usage: +------- + +./fuse-aexplorer /mnt/amiga /dev/ttyUSB0 19200 + +cp -a myfile "/mnt/amiga/RAM\:Ram\ Disk/" + + + + +DATA LOSS WARNING +================== + +Comments and attributes without an equivalence in Linux are lost +whenever a write operation (including changing attributes) is +performed on a file. + +I'm sure there's more, but I've forgotten about it. + + + +Things that are broken +======================= + +A lot. The Amiga Explorer protocol really isn't meant to be used as +a "network file system". + +Examples: + + - Reading only a part of a file isn't possible. + There is a hack to only read the start of it, thus making Thunar + and other GIO programs not get stuck completely. + + - Changing a part of a file or appending data isn't possible - it + has to be rewritten from scratch. + We're buffering a file and send it when flush() is called. + + - Setting a file's modification date can only be done when + writing it from scratch. + Use 'cp -a' to copy your files. + + - Files and folders being deleted on the Amiga while we know that they + exist (i.e. their attributes are cached) causes undefined behavior. + + - And many more hacks! + Really, go use the original software instead! + + + +TODO +===== + +Search the code for TODO and NOTE. + +:-) + + + +Thanks +======= + +Cloanto for Amiga Explorer. Great stuff and a life saver. + +Mark Street for lxamiga and +[lxamiga.pl](https://github.com/marksmanuk/lxamiga) - this gave me a +head start in understanding the protocol. + + + +License +======== + +GNU General Public License v2 only. diff --git a/ae-protocol.md b/ae-protocol.md new file mode 100644 index 0000000..91bd860 --- /dev/null +++ b/ae-protocol.md @@ -0,0 +1,206 @@ +Message types +============== + + Msg ID | Sending side | Description + --------|--------------|---------------------------------- + 00 | Ami/PC | Ask for next block + 01 | Ami/PC | Transfer cancelled + 02 | Ami/PC | Initialisation / Init response + 03 | Ami/? | Multipart header + 04 | Amiga | EOF (no payload) + 05 | Amiga | Next data block + + 08 | Amiga | File already exists (when trying to write with 0x66) + 09 | Amiga | Size ? (response to 0x6c) + 0a | Amiga | Close response + 0b | Amiga | Format response? + + 64 | PC | List directory + 65 | PC | File read + 66 | PC | File/folder write + 67 | PC | File/folder delete (recursively) + 68 | PC | File rename (name changes) (works on drives, too?) + 69 | PC | File move (path changes) + 6a | PC | File copy + 6b | PC | Set attributes and comment + 6c | PC | Request size on disk (?) + 6d | PC | Close file + 6e | PC | Format disk (needs Kickstart 2.0 or newer) + 6f | PC | New folder + + + + +Request details +================ + +64 - List a directory +---------------------- + +### TODO ### + + +65 - Read a file +----------------- + +### TODO ### + + +66 - Write a file +------------------ + +### TODO ### + + +67 - Delete file/folder +------------------------ + +Payload: + + Bytes | Content + -------|-------------------- + n | Path + 1 | 0x00 + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + +If Path is a folder, it will be deleted together with its contents. + + +68 - Rename file/folder +------------------------ + +Payload: + + Bytes | Content + -------|-------------------- + n | Path (including old file name) + 1 | 0x00 + n | New file name (without path) + 1 | 0x00 + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + + +69 - Move file/folder +---------------------- + +Payload: + + Bytes | Content + -------|-------------------- + n | Path (including old file name) + 1 | 0x00 + n | New path to contain file (folder without trailing slash or file name) + 1 | 0x00 + 1 | 0xc9 (?) + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + +If Path is a folder, it will be moved together with its contents. +This command appears to work across devices. + + +6a - Copy file/folder +---------------------- + +Payload: + + Bytes | Content + -------|-------------------- + n | Path (including old file name) + 1 | 0x00 + n | New path to contain file (folder without trailing slash or file name) + 1 | 0x00 + 1 | 0xc9 (?) + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + +If Path is a folder, it will be moved together with its contents. +This command appears to work across devices. + + +6b - Set attributes and comment +-------------------------------- + +Payload: + + Bytes | Content + -------|-------------------- + 4 | Attributes + n | Path + 1 | 0x00 + n | Comment + 1 | 0x00 + 4 | Checksum? (seems to be 0x00000000 if comment empty) + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + + +6c - Request size on disk (?) +------------------------------------ + +Payload: + + Bytes | Content + -------|-------------------- + n | Path + 1 | 0x00 + +Then, read type 0 for confirmation. +Then, read type 9 for 12 bytes of payload (### TODO ###). +Then, send type 0 to request more data (no payload). +Then, read type 9 for 12 bytes of payload (### TODO ###). +Then, send type 0 to request more data (no payload). +Then, read type 0 signaling end of attributes (no payload). +Then, sendClose() (0xa response: 5x 00). + +OR + +Then, read type 0 for confirmation. +Then, read type 0 signaling end of attributes (no payload). +Then, sendClose() (0xa response: 5x 00). + + + +6d - Close file +---------------- + +No payload. + +Then, read type 0x0a for confirmation (typical payload: 5 bytes of 0x00). + +This is used to finish an operation, such as a directory listing +or renaming a file. + + +6e - Format disk +----------------- + +### TODO ### + + +6f - New folder +---------------- + +Payload: + + Bytes | Content + -------|-------------------- + n | Parent path + 1 | 0x00 + +Then, read type 0 for confirmation. +Then, sendClose() (0xa response: 5x 00). + +The host will create a new folder in the given path, together with a +matching .info file. +The folder name cannot be chosen, and will be something like "Unnamed1". + +To create a folder with a specific name, use 0x66. +Note that 0x66'ing a folder does not seem to set its time. diff --git a/fuse-aexplorer.py b/fuse-aexplorer.py new file mode 100755 index 0000000..41c8593 --- /dev/null +++ b/fuse-aexplorer.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# +# A FUSE filesystem to access an Amiga's files via Cloanto's Amiga Explorer. +# +# Known limitations: +# See README. +# +# SPDX-License-Identifier: GPL-2.0 +# + + +from fusepy import FUSE +import sys + +from AEpy.AELink import AELinkSerial +from AEpy.AESession import AESession +from AEpy.AEFuse import AEFuse +from AEpy import FusePatches + + + + + +if __name__ == '__main__': + # Monkey patch fusepy + FusePatches.patch_max_readahead(512) + + serspeed = 19200 + + if len(sys.argv) - 1 < 2: + print('Only ' + str(len(sys.argv) - 1) + ' arguments given.') + print('Usage: ' + sys.argv[0] + ' [serial speed]') + + sys.exit(-1) + + if len(sys.argv) - 1 > 2: + serspeed = int(sys.argv[3]) + + link = AELinkSerial(sys.argv[2], serspeed) + session = AESession(link) + fuse = AEFuse(session) + FUSE(fuse, sys.argv[1], nothreads=True, foreground=True)