Import first working version
authornorly <ny-git@enpas.org>
Sun, 16 Dec 2018 22:05:33 +0000 (23:05 +0100)
committernorly <ny-git@enpas.org>
Sun, 16 Dec 2018 22:21:11 +0000 (23:21 +0100)
13 files changed:
.gitignore [new file with mode: 0644]
AEpy/AECmds.py [new file with mode: 0644]
AEpy/AEFuse.py [new file with mode: 0644]
AEpy/AELink.py [new file with mode: 0644]
AEpy/AEMultipart.py [new file with mode: 0644]
AEpy/AESession.py [new file with mode: 0644]
AEpy/FuseCache.py [new file with mode: 0644]
AEpy/FusePatches.py [new file with mode: 0644]
AEpy/__init__.py [new file with mode: 0644]
COPYING [new file with mode: 0644]
README.md [new file with mode: 0644]
ae-protocol.md [new file with mode: 0644]
fuse-aexplorer.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..15584ca
--- /dev/null
@@ -0,0 +1 @@
+.pyc
diff --git a/AEpy/AECmds.py b/AEpy/AECmds.py
new file mode 100644 (file)
index 0000000..a9ccd99
--- /dev/null
@@ -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 (file)
index 0000000..fea6037
--- /dev/null
@@ -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 (file)
index 0000000..e75f3fb
--- /dev/null
@@ -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 (file)
index 0000000..a542bfa
--- /dev/null
@@ -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 (file)
index 0000000..145fff7
--- /dev/null
@@ -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 (file)
index 0000000..420b992
--- /dev/null
@@ -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 (file)
index 0000000..f02ddd2
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
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.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    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.
+
+  <signature of Ty Coon>, 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 (file)
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 (file)
index 0000000..91bd860
--- /dev/null
@@ -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 (executable)
index 0000000..41c8593
--- /dev/null
@@ -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] + ' <mount point> <serial device> [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)