New SFTP implementation: mutable files, read/write support, streaming download, Unico...
authordavid-sarah <david-sarah@jacaranda.org>
Wed, 12 May 2010 05:54:07 +0000 (22:54 -0700)
committerdavid-sarah <david-sarah@jacaranda.org>
Wed, 12 May 2010 05:54:07 +0000 (22:54 -0700)
src/allmydata/frontends/sftpd.py

index fa52df528ea85f00c3e05d1083d0790f1e4366c2..2abd6b01c0fef41c8e66d43aeb4043efdc3ddcdd 100644 (file)
 
-import tempfile
+import os, tempfile, heapq, binascii, traceback, sys
+from stat import S_IFREG, S_IFDIR
+
 from zope.interface import implements
 from twisted.python import components
 from twisted.application import service, strports
-from twisted.internet import defer
 from twisted.conch.ssh import factory, keys, session
+from twisted.conch.ssh.filetransfer import FileTransferServer, SFTPError, \
+     FX_NO_SUCH_FILE, FX_OP_UNSUPPORTED, FX_PERMISSION_DENIED, FX_EOF, \
+     FX_BAD_MESSAGE, FX_FAILURE
+from twisted.conch.ssh.filetransfer import FXF_READ, FXF_WRITE, FXF_APPEND, \
+     FXF_CREAT, FXF_TRUNC, FXF_EXCL
 from twisted.conch.interfaces import ISFTPServer, ISFTPFile, IConchUser
 from twisted.conch.avatar import ConchUser
 from twisted.conch.openssh_compat import primes
-from twisted.conch import ls
 from twisted.cred import portal
 
-from allmydata.interfaces import IDirectoryNode, ExistingChildError, \
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from twisted.internet.interfaces import IFinishableConsumer
+from foolscap.api import eventually\r
+from allmydata.util import deferredutil
+
+from allmydata.util.consumer import download_to_data
+from allmydata.interfaces import IFileNode, IDirectoryNode, ExistingChildError, \
      NoSuchChildError
+from allmydata.mutable.common import NotWriteableError
 from allmydata.immutable.upload import FileHandle
-from allmydata.util.consumer import download_to_data
 
-class ReadFile:
+from pycryptopp.cipher.aes import AES
+
+# twisted.conch.ssh.filetransfer generates this warning, but not when it is imported,
+# only on an error.
+import warnings
+warnings.filterwarnings("ignore", category=DeprecationWarning,
+    message="BaseException.message has been deprecated as of Python 2.6",
+    module=".*filetransfer", append=True)
+
+debug = False
+
+if debug:
+    def eventually_callback(d):
+        s = traceback.format_stack()
+        def _cb(res):
+            try:
+                print "CALLBACK %r %r" % (d, res)
+                d.callback(res)
+            except:  # pragma: no cover
+                print "Failed to callback %r" % (d,)
+                print "with %r" % (res,)
+                print "Original stack:"
+                print '!' + '!'.join(s)
+                traceback.print_exc()
+                raise
+        return lambda res: eventually(_cb, res)
+
+    def eventually_errback(d):
+        s = traceback.format_stack()
+        def _eb(err):
+            try:
+                print "ERRBACK", d, err
+                d.errback(err)
+            except:  # pragma: no cover
+                print "Failed to errback %r" % (d,)
+                print "with %r" % (err,)
+                print "Original stack:"
+                print '!' + '!'.join(s)
+                traceback.print_exc()
+                raise
+        return lambda err: eventually(_eb, err)
+else:
+    def eventually_callback(d):
+        return lambda res: eventually(d.callback, res)
+
+    def eventually_errback(d):
+        return lambda err: eventually(d.errback, err)
+
+
+def _raise_error(err):
+    if err is None:
+        return None
+    if debug:
+        print "TRACEBACK %r" % (err,)
+        #traceback.print_exc(err)
+
+    # The message argument to SFTPError must not reveal information that
+    # might compromise anonymity.
+
+    if err.check(SFTPError):
+        # original raiser of SFTPError has responsibility to ensure anonymity
+        raise err
+    if err.check(NoSuchChildError):
+        childname = err.value.args[0].encode('utf-8')
+        raise SFTPError(FX_NO_SUCH_FILE, childname)
+    if err.check(ExistingChildError):
+        msg = err.value.args[0].encode('utf-8')
+        # later versions of SFTP define FX_FILE_ALREADY_EXISTS, but version 3 doesn't
+        raise SFTPError(FX_PERMISSION_DENIED, msg)
+    if err.check(NotWriteableError):
+        msg = err.value.args[0].encode('utf-8')
+        raise SFTPError(FX_PERMISSION_DENIED, msg)
+    if err.check(NotImplementedError):
+        raise SFTPError(FX_OP_UNSUPPORTED, str(err.value))
+    if err.check(EOFError):
+        raise SFTPError(FX_EOF, "end of file reached")
+    if err.check(defer.FirstError):
+        _raise_error(err.value.subFailure)
+
+    # We assume that the type of error is not anonymity-sensitive.
+    raise SFTPError(FX_FAILURE, str(err.type))
+
+def _repr_flags(flags):
+    return "|".join([f for f in
+                     [(flags & FXF_READ) and "FXF_READ" or None,
+                      (flags & FXF_WRITE) and "FXF_WRITE" or None,
+                      (flags & FXF_APPEND) and "FXF_APPEND" or None,
+                      (flags & FXF_CREAT) and "FXF_CREAT" or None,
+                      (flags & FXF_TRUNC) and "FXF_TRUNC" or None,
+                      (flags & FXF_EXCL) and "FXF_EXCL" or None,
+                     ]
+                     if f])
+
+def _populate_attrs(childnode, metadata, writeable, size=None):
+    attrs = {}
+
+    # see webapi.txt for what these times mean
+    if metadata:
+        if "linkmotime" in metadata.get("tahoe", {}):
+            attrs["mtime"] = int(metadata["tahoe"]["linkmotime"])
+        elif "mtime" in metadata:
+            attrs["mtime"] = int(metadata["mtime"])
+
+        if "linkcrtime" in metadata.get("tahoe", {}):
+            attrs["createtime"] = int(metadata["tahoe"]["linkcrtime"])
+
+        if "ctime" in metadata:
+            attrs["ctime"] = int(metadata["ctime"])
+
+        # We would prefer to omit atime, but SFTP version 3 can only
+        # accept mtime if atime is also set.
+        attrs["atime"] = attrs["mtime"]
+
+    # The permissions must have the extra bits (040000 or 0100000),
+    # otherwise the client will not call openDirectory.
+
+    # Directories and unknown nodes have no size, and SFTP doesn't
+    # require us to make one up.
+    # childnode might be None, meaning that the file doesn't exist yet,
+    # but we're going to write it later.
+
+    if childnode and childnode.is_unknown():
+        perms = 0
+    elif childnode and IDirectoryNode.providedBy(childnode):
+        perms = S_IFDIR | 0770 
+    else:
+        # For files, omit the size if we don't immediately know it.
+        if childnode and size is None:
+            size = childnode.get_size()
+        if size is not None:
+            assert isinstance(size, (int, long)), repr(size)
+            attrs["size"] = size
+        perms = S_IFREG | 0660
+
+    if not writeable:
+        perms &= S_IFDIR | S_IFREG | 0555  # clear 'w' bits
+
+    attrs["permissions"] = perms
+
+    # We could set the SSH_FILEXFER_ATTR_FLAGS here:
+    # ENCRYPTED would always be true ("The file is stored on disk\r
+    # using file-system level transparent encryption.")
+    # SYSTEM, HIDDEN, ARCHIVE and SYNC would always be false.
+    # READONLY and IMMUTABLE would be set according to
+    # childnode.is_readonly() and childnode.is_immutable()
+    # for known nodes.
+    # However, twisted.conch.ssh.filetransfer only implements
+    # SFTP version 3, which doesn't include these flags.
+
+    return attrs
+
+
+class EncryptedTemporaryFile:
+    # not implemented: next, readline, readlines, xreadlines, writelines
+
+    def _crypt(self, offset, data):
+        # FIXME: use random-access AES (pycryptopp ticket #18)
+        offset_big = offset // 16\r
+        offset_small = offset % 16\r
+        iv = binascii.unhexlify("%032x" % offset_big)\r
+        cipher = AES(self.key, iv=iv)\r
+        cipher.process("\x00"*offset_small)\r
+        return cipher.process(data)\r
+
+    def __init__(self):
+        self.file = tempfile.TemporaryFile()
+        self.key = os.urandom(16)  # AES-128
+
+    def close(self):
+        self.file.close()
+
+    def flush(self):
+        self.file.flush()
+
+    def seek(self, offset, whence=os.SEEK_SET):
+        if debug: print ".seek(%r, %r)" % (offset, whence)
+        self.file.seek(offset, whence)
+
+    def tell(self):
+        offset = self.file.tell()
+        if debug: print ".offset = %r" % (offset,)
+        return offset
+
+    def read(self, size=-1):
+        if debug: print ".read(%r)" % (size,)
+        index = self.file.tell()
+        ciphertext = self.file.read(size)
+        plaintext = self._crypt(index, ciphertext)
+        return plaintext
+
+    def write(self, plaintext):
+        if debug: print ".write(%r)" % (plaintext,)
+        index = self.file.tell()
+        ciphertext = self._crypt(index, plaintext)
+        self.file.write(ciphertext)
+
+    def truncate(self, newsize):
+        if debug: print ".truncate(%r)" % (newsize,)
+        self.file.truncate(newsize)
+
+
+class OverwriteableFileConsumer:
+    implements(IFinishableConsumer)
+    """I act both as a consumer for the download of the original file contents, and as a
+    wrapper for a temporary file that records the downloaded data and any overwrites.
+    I use a priority queue to keep track of which regions of the file have been overwritten
+    but not yet downloaded, so that the download does not clobber overwritten data.
+    I use another priority queue to record milestones at which to make callbacks
+    indicating that a given number of bytes have been downloaded.
+
+    The temporary file reflects the contents of the file that I represent, except that:
+     - regions that have neither been downloaded nor overwritten, if present,
+       contain zeroes.
+     - the temporary file may be shorter than the represented file (it is never longer).
+       The latter's current size is stored in self.current_size.
+
+    This abstraction is mostly independent of SFTP. Consider moving it, if it is found
+    useful for other frontends."""
+
+    def __init__(self, check_abort, download_size, tempfile_maker):
+        self.check_abort = check_abort
+        self.download_size = download_size
+        self.current_size = download_size
+        self.f = tempfile_maker()
+        self.downloaded = 0
+        self.milestones = []  # empty heap of (offset, d)
+        self.overwrites = []  # empty heap of (start, end)
+        self.done = self.when_reached(download_size)  # adds a milestone
+        self.producer = None
+
+    def get_file(self):
+        return self.f
+
+    def get_current_size(self):
+        return self.current_size
+
+    def set_current_size(self, size):
+        if debug: print "set_current_size(%r), current_size = %r, downloaded = %r" % (size, self.current_size, self.downloaded)
+        if size < self.current_size or size < self.downloaded:
+            self.f.truncate(size)
+        self.current_size = size
+        if size < self.download_size:
+            self.download_size = size
+        if self.downloaded >= self.download_size:
+            self.finish()
+
+    def registerProducer(self, p, streaming):
+        self.producer = p
+        if streaming:
+            # call resumeProducing once to start things off
+            p.resumeProducing()
+        else:
+            while not self.done:
+                p.resumeProducing()
+
+    def write(self, data):
+        if debug: print "write(%r)" % (data,)
+        if self.check_abort():
+            self.close()
+            return
+
+        if self.downloaded >= self.download_size:
+            return
+
+        next_downloaded = self.downloaded + len(data)
+        if next_downloaded > self.download_size:
+            data = data[:(self.download_size - self.downloaded)]
+
+        while len(self.overwrites) > 0:
+            (start, end) = self.overwrites[0]
+            if start >= next_downloaded:
+                # This and all remaining overwrites are after the data we just downloaded.
+                break
+            if start > self.downloaded:
+                # The data we just downloaded has been partially overwritten.
+                # Write the prefix of it that precedes the overwritten region.
+                self.f.seek(self.downloaded)
+                self.f.write(data[:(start - self.downloaded)])
+
+            # This merges consecutive overwrites if possible, which allows us to detect the
+            # case where the download can be stopped early because the remaining region
+            # to download has already been fully overwritten.
+            heapq.heappop(self.overwrites)
+            while len(self.overwrites) > 0:
+                (start1, end1) = self.overwrites[0]
+                if start1 > end:
+                    break
+                end = end1
+                heapq.heappop(self.overwrites)
+
+            if end >= next_downloaded:
+                # This overwrite extends past the downloaded data, so there is no
+                # more data to consider on this call.
+                heapq.heappush(self.overwrites, (next_downloaded, end))
+                self._update_downloaded(next_downloaded)
+                return
+            elif end >= self.downloaded:
+                data = data[(end - self.downloaded):]
+                self._update_downloaded(end)
+
+        self.f.seek(self.downloaded)
+        self.f.write(data)
+        self._update_downloaded(next_downloaded)
+
+    def _update_downloaded(self, new_downloaded):
+        self.downloaded = new_downloaded
+        milestone = new_downloaded
+        if len(self.overwrites) > 0:
+            (start, end) = self.overwrites[0]
+            if start <= new_downloaded and end > milestone:
+                milestone = end
+
+        while len(self.milestones) > 0:
+            (next, d) = self.milestones[0]
+            if next > milestone:
+                return
+            if debug: print "MILESTONE %r %r" % (next, d)
+            heapq.heappop(self.milestones)
+            eventually_callback(d)(None)
+
+        if milestone >= self.download_size:
+            self.finish()
+
+    def overwrite(self, offset, data):
+        if debug: print "overwrite(%r, %r)" % (offset, data)
+        if offset > self.download_size and offset > self.current_size:
+            # Normally writing at an offset beyond the current end-of-file
+            # would leave a hole that appears filled with zeroes. However, an
+            # EncryptedTemporaryFile doesn't behave like that (if there is a
+            # hole in the file on disk, the zeroes that are read back will be
+            # XORed with the keystream). So we must explicitly write zeroes in
+            # the gap between the current EOF and the offset.
+
+            self.f.seek(self.current_size)
+            self.f.write("\x00" * (offset - self.current_size))            
+        else:
+            self.f.seek(offset)
+        self.f.write(data)
+        end = offset + len(data)
+        self.current_size = max(self.current_size, end)
+        if end > self.downloaded:
+            heapq.heappush(self.overwrites, (offset, end))
+
+    def read(self, offset, length):
+        """When the data has been read, callback the Deferred that we return with this data.
+        Otherwise errback the Deferred that we return.
+        The caller must perform no more overwrites until the Deferred has fired."""
+
+        if debug: print "read(%r, %r), current_size = %r" % (offset, length, self.current_size)
+        if offset >= self.current_size:
+            def _eof(): raise EOFError("read past end of file")
+            return defer.execute(_eof)
+
+        if offset + length > self.current_size:
+            length = self.current_size - offset
+
+        needed = min(offset + length, self.download_size)
+        d = self.when_reached(needed)
+        def _reached(ign):
+            # It is not necessarily the case that self.downloaded >= needed, because
+            # the file might have been truncated (thus truncating the download) and
+            # then extended.
+
+            assert self.current_size >= offset + length, (self.current_size, offset, length)
+            if debug: print "!!! %r" % (self.f,)
+            self.f.seek(offset)
+            return self.f.read(length)
+        d.addCallback(_reached)
+        return d
+
+    def when_reached(self, index):
+        if debug: print "when_reached(%r)" % (index,)
+        if index <= self.downloaded:  # already reached
+            if debug: print "already reached %r" % (index,)
+            return defer.succeed(None)
+        d = defer.Deferred()
+        def _reached(ign):
+            if debug: print "reached %r" % (index,)
+            return ign
+        d.addCallback(_reached)
+        heapq.heappush(self.milestones, (index, d))
+        return d
+
+    def when_done(self):
+        return self.done
+
+    def finish(self):
+        while len(self.milestones) > 0:
+            (next, d) = self.milestones[0]
+            if debug: print "MILESTONE FINISH %r %r" % (next, d)
+            heapq.heappop(self.milestones)
+            # The callback means that the milestone has been reached if
+            # it is ever going to be. Note that the file may have been
+            # truncated to before the milestone.
+            eventually_callback(d)(None)
+
+        # FIXME: causes spurious failures
+        #self.unregisterProducer()
+
+    def close(self):
+        self.finish()
+        self.f.close()
+
+    def unregisterProducer(self):
+        if self.producer:
+            self.producer.stopProducing()
+            self.producer = None
+
+
+SIZE_THRESHOLD = 1000
+
+def _make_sftp_file(check_abort, flags, convergence, parent=None, childname=None, filenode=None, metadata=None):
+    if not (flags & (FXF_WRITE | FXF_CREAT)) and (flags & FXF_READ) and filenode and \
+       not filenode.is_mutable() and filenode.get_size() <= SIZE_THRESHOLD:
+        return ShortReadOnlySFTPFile(filenode, metadata)
+    else:
+        return GeneralSFTPFile(check_abort, flags, convergence,
+                               parent=parent, childname=childname, filenode=filenode, metadata=metadata)
+
+
+class ShortReadOnlySFTPFile:
     implements(ISFTPFile)
-    def __init__(self, node):
-        self.node = node
+    """I represent a file handle to a particular file on an SFTP connection.
+    I am used only for short immutable files opened in read-only mode.
+    The file contents are downloaded to memory when I am created."""
+
+    def __init__(self, filenode, metadata):
+        assert IFileNode.providedBy(filenode), filenode
+        self.filenode = filenode
+        self.metadata = metadata
+        self.async = download_to_data(filenode)
+        self.closed = False
+
     def readChunk(self, offset, length):
-        d = download_to_data(self.node, offset, length)
-        def _got(data):
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot read from a closed file handle")
+            return defer.execute(_closed)
+
+        d = defer.Deferred()
+        def _read(data):
+            if debug: print "_read(%r) in readChunk(%r, %r)" % (data, offset, length)
+
+            # "In response to this request, the server will read as many bytes as it\r
+            #  can from the file (up to 'len'), and return them in a SSH_FXP_DATA\r
+            #  message.  If an error occurs or EOF is encountered before reading any\r
+            #  data, the server will respond with SSH_FXP_STATUS.  For normal disk\r
+            #  files, it is guaranteed that this will read the specified number of\r
+            #  bytes, or up to end of file."
+            #
+            # i.e. we respond with an EOF error iff offset is already at EOF.
+
+            if offset >= len(data):
+                eventually_errback(d)(SFTPError(FX_EOF, "read at or past end of file"))
+            else:
+                eventually_callback(d)(data[offset:min(offset+length, len(data))])
             return data
-        d.addCallback(_got)
+        self.async.addCallbacks(_read, eventually_errback(d))
         return d
+
+    def writeChunk(self, offset, data):
+        def _denied(): raise SFTPError(FX_PERMISSION_DENIED, "file handle was not opened for writing")
+        return defer.execute(_denied)
+
     def close(self):
-        pass
+        self.closed = True
+        return defer.succeed(None)
+
     def getAttrs(self):
-        print "GETATTRS(file)"
-        raise NotImplementedError
+        if debug: print "GETATTRS(file)"
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot get attributes for a closed file handle")
+            return defer.execute(_closed)
+
+        return defer.succeed(_populate_attrs(self.filenode, self.metadata, False))
+
     def setAttrs(self, attrs):
-        print "SETATTRS(file)", attrs
-        raise NotImplementedError
+        if debug: print "SETATTRS(file) %r" % (attrs,)
+        def _denied(): raise SFTPError(FX_PERMISSION_DENIED, "file handle was not opened for writing")
+        return defer.execute(_denied)
+
 
-class WriteFile:
+class GeneralSFTPFile:
     implements(ISFTPFile)
+    """I represent a file handle to a particular file on an SFTP connection.
+    I wrap an instance of OverwriteableFileConsumer, which is responsible for
+    storing the file contents. In order to allow write requests to be satisfied
+    immediately, there is effectively a FIFO queue between requests made to this
+    file handle, and requests to my OverwriteableFileConsumer. This queue is
+    implemented by the callback chain of self.async."""
 
-    def __init__(self, parent, childname, convergence):
+    def __init__(self, check_abort, flags, convergence, parent=None, childname=None, filenode=None, metadata=None):
+        self.check_abort = check_abort
+        self.flags = flags
+        self.convergence = convergence
         self.parent = parent
         self.childname = childname
-        self.convergence = convergence
-        self.f = tempfile.TemporaryFile()
+        self.filenode = filenode
+        self.metadata = metadata
+        self.async = defer.succeed(None)
+        self.closed = False
+        
+        # self.consumer should only be relied on in callbacks for self.async, since it might
+        # not be set before then.
+        self.consumer = None
+
+        if (flags & FXF_TRUNC) or not filenode:
+            # We're either truncating or creating the file, so we don't need the old contents.
+            assert flags & FXF_CREAT, flags
+            self.consumer = OverwriteableFileConsumer(self.check_abort, 0,
+                                                      tempfile_maker=EncryptedTemporaryFile)
+            self.consumer.finish()
+        else:
+            assert IFileNode.providedBy(filenode), filenode
+
+            # TODO: use download interface described in #993 when implemented.
+            if filenode.is_mutable():
+                self.async.addCallback(lambda ign: filenode.download_best_version())
+                def _downloaded(data):
+                    self.consumer = OverwriteableFileConsumer(self.check_abort, len(data),
+                                                              tempfile_maker=tempfile.TemporaryFile)
+                    self.consumer.write(data)
+                    self.consumer.finish()
+                    return None
+                self.async.addCallback(_downloaded)
+            else:
+                download_size = filenode.get_size()
+                assert download_size is not None
+                self.consumer = OverwriteableFileConsumer(self.check_abort, download_size,
+                                                          tempfile_maker=tempfile.TemporaryFile)
+                self.async.addCallback(lambda ign: filenode.read(self.consumer, 0, None))
+
+
+    def readChunk(self, offset, length):
+        if not (self.flags & FXF_READ):
+            return defer.fail(SFTPError(FX_PERMISSION_DENIED, "file handle was not opened for reading"))
+
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot read from a closed file handle")
+            return defer.execute(_closed)
+
+        d = defer.Deferred()
+        def _read(ign):
+            if debug: print "_read in readChunk(%r, %r)" % (offset, length)
+            d2 = self.consumer.read(offset, length)
+            d2.addErrback(_raise_error)
+            d2.addCallbacks(eventually_callback(d), eventually_errback(d))
+            # It is correct to drop d2 here.
+            return None
+        self.async.addCallbacks(_read, eventually_errback(d))
+        return d
+
     def writeChunk(self, offset, data):
-        self.f.seek(offset)
-        self.f.write(data)
+        if debug: print "writeChunk(%r, %r)" % (offset, data)
+        if not (self.flags & FXF_WRITE):
+            def _denied(): raise SFTPError(FX_PERMISSION_DENIED, "file handle was not opened for writing")
+            return defer.execute(_denied)
+
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot write to a closed file handle")
+            return defer.execute(_closed)
+
+        # Note that we return without waiting for the write to occur. Reads and
+        # close wait for prior writes, and will fail if any prior operation failed.
+        # This is ok because SFTP makes no guarantee that the request completes
+        # before the write. In fact it explicitly allows write errors to be delayed
+        # until close:
+        #   "One should note that on some server platforms even a close can fail.\r
+        #    This can happen e.g. if the server operating system caches writes,\r
+        #    and an error occurs while flushing cached writes during the close."
+
+        def _write(ign):
+            # FXF_APPEND means that we should always write at the current end of file.
+            write_offset = offset
+            if self.flags & FXF_APPEND:
+                write_offset = self.consumer.get_current_size()
+
+            self.consumer.overwrite(write_offset, data)
+            return None
+        self.async.addCallback(_write)
+        # don't addErrback to self.async, just allow subsequent async ops to fail.
+        return defer.succeed(None)
 
     def close(self):
-        u = FileHandle(self.f, self.convergence)
-        d = self.parent.add_file(self.childname, u)
+        if self.closed:
+            return defer.succeed(None)
+
+        # This means that close has been called, not that the close has succeeded.
+        self.closed = True
+
+        if not (self.flags & (FXF_WRITE | FXF_CREAT)):
+            return defer.execute(self.consumer.close)
+
+        def _close(ign):
+            d2 = self.consumer.when_done()
+            if self.filenode and self.filenode.is_mutable():
+                d2.addCallback(lambda ign: self.consumer.get_current_size())
+                d2.addCallback(lambda size: self.consumer.read(0, size))
+                d2.addCallback(lambda new_contents: self.filenode.overwrite(new_contents))
+            else:
+                def _add_file(ign):
+                    u = FileHandle(self.consumer.get_file(), self.convergence)
+                    return self.parent.add_file(self.childname, u)
+                d2.addCallback(_add_file)
+
+            d2.addCallback(lambda ign: self.consumer.close())
+            return d2
+        self.async.addCallback(_close)
+
+        d = defer.Deferred()
+        self.async.addCallbacks(eventually_callback(d), eventually_errback(d))
         return d
 
     def getAttrs(self):
-        print "GETATTRS(file)"
-        raise NotImplementedError
+        if debug: print "GETATTRS(file)"
+
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot get attributes for a closed file handle")
+            return defer.execute(_closed)
+
+        # Optimization for read-only handles, when we already know the metadata.
+        if not(self.flags & (FXF_WRITE | FXF_CREAT)) and self.metadata and self.filenode and not self.filenode.is_mutable():
+            return defer.succeed(_populate_attrs(self.filenode, self.metadata, False))
+
+        d = defer.Deferred()
+        def _get(ign):
+            # FIXME: pass correct value for writeable
+            # self.filenode might be None, but that's ok.
+            attrs = _populate_attrs(self.filenode, self.metadata, False,
+                                    size=self.consumer.get_current_size())
+            eventually_callback(d)(attrs)
+            return None
+        self.async.addCallbacks(_get, eventually_errback(d))
+        return d
+
     def setAttrs(self, attrs):
-        print "SETATTRS(file)", attrs
-        raise NotImplementedError
+        if debug: print "SETATTRS(file) %r" % (attrs,)
+        if not (self.flags & FXF_WRITE):
+            def _denied(): raise SFTPError(FX_PERMISSION_DENIED, "file handle was not opened for writing")
+            return defer.execute(_denied)
 
+        if self.closed:
+            def _closed(): raise SFTPError(FX_BAD_MESSAGE, "cannot set attributes for a closed file handle")
+            return defer.execute(_closed)
 
-class NoParentError(Exception):
-    pass
+        if not "size" in attrs:
+            return defer.succeed(None)
 
-class PermissionError(Exception):
-    pass
+        size = attrs["size"]
+        if not isinstance(size, (int, long)) or size < 0:
+            def _bad(): raise SFTPError(FX_BAD_MESSAGE, "new size is not a valid nonnegative integer")
+            return defer.execute(_bad)
 
-from twisted.conch.ssh.filetransfer import FileTransferServer, SFTPError, \
-     FX_NO_SUCH_FILE, FX_FILE_ALREADY_EXISTS, FX_OP_UNSUPPORTED, \
-     FX_PERMISSION_DENIED
-from twisted.conch.ssh.filetransfer import FXF_READ, FXF_WRITE, FXF_APPEND, FXF_CREAT, FXF_TRUNC, FXF_EXCL
+        d = defer.Deferred()
+        def _resize(ign):
+            self.consumer.set_current_size(size)
+            eventually_callback(d)(None)
+            return None
+        self.async.addCallbacks(_resize, eventually_errback(d))
+        return d
 
 class SFTPUser(ConchUser):
-    def __init__(self, client, rootnode, username, convergence):
+    def __init__(self, check_abort, client, rootnode, username, convergence):
         ConchUser.__init__(self)
         self.channelLookup["session"] = session.SSHSession
         self.subsystemLookup["sftp"] = FileTransferServer
 
+        self.check_abort = check_abort
         self.client = client
         self.root = rootnode
         self.username = username
@@ -91,130 +709,276 @@ class StoppableList:
     def close(self):
         pass
 
-class FakeStat:
-    pass
+import array
+import stat
+
+from time import time, strftime, localtime
+
+def lsLine(name, attrs):
+    st_uid = "tahoe"
+    st_gid = "tahoe"
+    st_mtime = attrs.get("mtime", 0)
+    st_mode = attrs["permissions"]
+    # TODO: check that clients are okay with this being a "?".
+    # (They should be because the longname is intended for human
+    # consumption.)
+    st_size = attrs.get("size", "?")
+    # We don't know how many links there really are to this object.
+    st_nlink = 1
+
+    # From <http://twistedmatrix.com/trac/browser/trunk/twisted/conch/ls.py?rev=25412>.
+    # We can't call the version in Twisted because we might have a version earlier than
+    # <http://twistedmatrix.com/trac/changeset/25412> (released in Twisted 8.2).
+
+    mode = st_mode
+    perms = array.array('c', '-'*10)
+    ft = stat.S_IFMT(mode)
+    if stat.S_ISDIR(ft): perms[0] = 'd'
+    elif stat.S_ISCHR(ft): perms[0] = 'c'
+    elif stat.S_ISBLK(ft): perms[0] = 'b'
+    elif stat.S_ISREG(ft): perms[0] = '-'
+    elif stat.S_ISFIFO(ft): perms[0] = 'f'
+    elif stat.S_ISLNK(ft): perms[0] = 'l'
+    elif stat.S_ISSOCK(ft): perms[0] = 's'
+    else: perms[0] = '?'
+    # user
+    if mode&stat.S_IRUSR:perms[1] = 'r'
+    if mode&stat.S_IWUSR:perms[2] = 'w'
+    if mode&stat.S_IXUSR:perms[3] = 'x'
+    # group
+    if mode&stat.S_IRGRP:perms[4] = 'r'
+    if mode&stat.S_IWGRP:perms[5] = 'w'
+    if mode&stat.S_IXGRP:perms[6] = 'x'
+    # other
+    if mode&stat.S_IROTH:perms[7] = 'r'
+    if mode&stat.S_IWOTH:perms[8] = 'w'
+    if mode&stat.S_IXOTH:perms[9] = 'x'
+    # suid/sgid never set
+
+    l = perms.tostring()
+    l += str(st_nlink).rjust(5) + ' '
+    un = str(st_uid)
+    l += un.ljust(9)
+    gr = str(st_gid)
+    l += gr.ljust(9)
+    sz = str(st_size)
+    l += sz.rjust(8)
+    l += ' '
+    sixmo = 60 * 60 * 24 * 7 * 26
+    if st_mtime + sixmo < time(): # last edited more than 6mo ago
+        l += strftime("%b %d  %Y ", localtime(st_mtime))
+    else:
+        l += strftime("%b %d %H:%M ", localtime(st_mtime))
+    l += name
+    return l
 
-class BadRemoveRequest(Exception):
-    pass
 
 class SFTPHandler:
     implements(ISFTPServer)
     def __init__(self, user):
-        print "Creating SFTPHandler from", user
+        if debug: print "Creating SFTPHandler from", user
+        self.check_abort = user.check_abort
         self.client = user.client
         self.root = user.root
         self.username = user.username
         self.convergence = user.convergence
 
     def gotVersion(self, otherVersion, extData):
+        if debug: print "GOTVERSION %r %r" % (otherVersion, extData)
         return {}
 
-    def openFile(self, filename, flags, attrs):
-        f = "|".join([f for f in
-                      [(flags & FXF_READ) and "FXF_READ" or None,
-                       (flags & FXF_WRITE) and "FXF_WRITE" or None,
-                       (flags & FXF_APPEND) and "FXF_APPEND" or None,
-                       (flags & FXF_CREAT) and "FXF_CREAT" or None,
-                       (flags & FXF_TRUNC) and "FXF_TRUNC" or None,
-                       (flags & FXF_EXCL) and "FXF_EXCL" or None,
-                      ]
-                      if f])
-        print "OPENFILE", filename, flags, f, attrs
+    def openFile(self, pathstring, flags, attrs):
+        if debug: print "OPENFILE %r %r %r %r" % (pathstring, flags, _repr_flags(flags), attrs)
         # this is used for both reading and writing.
 
-#        createPlease = False
-#        exclusive = False
-#        openFlags = 0
-#
-#        if flags & FXF_READ == FXF_READ and flags & FXF_WRITE == 0:
-#            openFlags = os.O_RDONLY
-#        if flags & FXF_WRITE == FXF_WRITE and flags & FXF_READ == 0:
-#            createPlease = True
-#            openFlags = os.O_WRONLY
-#        if flags & FXF_WRITE == FXF_WRITE and flags & FXF_READ == FXF_READ:
-#            createPlease = True
-#            openFlags = os.O_RDWR
-#        if flags & FXF_APPEND == FXF_APPEND:
-#            createPlease = True
-#            openFlags |= os.O_APPEND
-#        if flags & FXF_CREAT == FXF_CREAT:
-#            createPlease = True
-#            openFlags |= os.O_CREAT
-#        if flags & FXF_TRUNC == FXF_TRUNC:
-#            openFlags |= os.O_TRUNC
-#        if flags & FXF_EXCL == FXF_EXCL:
-#            exclusive = True
+        # First exclude invalid combinations of flags.
 
         # /usr/bin/sftp 'get' gives us FXF_READ, while 'put' on a new file
-        # gives FXF_WRITE,FXF_CREAT,FXF_TRUNC . I'm guessing that 'put' on an
+        # gives FXF_WRITE | FXF_CREAT | FXF_TRUNC. I'm guessing that 'put' on an
         # existing file gives the same.
 
-        path = self._convert_sftp_path(filename)
+        if not (flags & (FXF_READ | FXF_WRITE)):
+            raise SFTPError(FX_BAD_MESSAGE,
+                            "invalid file open flags: at least one of FXF_READ and FXF_WRITE must be set")
 
-        if flags & FXF_READ:
-            if flags & FXF_WRITE:
-                raise NotImplementedError
-            d = self._get_node_and_metadata_for_path(path)
-            d.addCallback(lambda (node,metadata): ReadFile(node))
-            d.addErrback(self._convert_error)
-            return d
+        if not (flags & FXF_CREAT):
+            if flags & FXF_TRUNC:
+                raise SFTPError(FX_BAD_MESSAGE,
+                                "invalid file open flags: FXF_TRUNC cannot be set without FXF_CREAT")
+            if flags & FXF_EXCL:
+                raise SFTPError(FX_BAD_MESSAGE,
+                                "invalid file open flags: FXF_EXCL cannot be set without FXF_CREAT")
 
-        if flags & FXF_WRITE:
-            if not (flags & FXF_CREAT) or not (flags & FXF_TRUNC):
-                raise NotImplementedError
+        path = self._path_from_string(pathstring)
+        if not path:
+            raise SFTPError(FX_NO_SUCH_FILE, "path cannot be empty")
+
+        # The combination of flags is potentially valid. Now there are two major cases:
+        #
+        #  1. The path is specified as /uri/FILECAP, with no parent directory.
+        #     If the FILECAP is mutable and writeable, then we can open it in write-only
+        #     or read/write mode (non-exclusively), otherwise we can only open it in
+        #     read-only mode. The open should succeed immediately as long as FILECAP is
+        #     a valid known filecap that grants the required permission.
+        #
+        #  2. The path is specified relative to a parent. We find the parent dirnode and
+        #     get the child's URI and metadata if it exists. There are four subcases:
+        #       a. the child does not exist: FXF_CREAT must be set, and we must be able
+        #          to write to the parent directory.
+        #       b. the child exists but is not a valid known filecap: fail
+        #       c. the child is mutable: if we are trying to open it write-only or
+        #          read/write, then we must be able to write to the file.
+        #       d. the child is immutable: if we are trying to open it write-only or
+        #          read/write, then we must be able to write to the parent directory.
+        #
+        # To reduce latency, open succeeds as soon as these conditions are met, even
+        # though there might be a failure in downloading the existing file or uploading
+        # a new one.
+        #
+        # Note that the permission checks below are for more precise error reporting on
+        # the open call; later operations would fail even if we did not make these checks.
+
+        stash = {'parent': None}
+        d = self._get_root(path)
+        def _got_root((root, path)):
+            if root.is_unknown():
+                raise SFTPError(FX_PERMISSION_DENIED,
+                                "cannot open an unknown cap (or child of an unknown directory). "
+                                "Upgrading the gateway to a later Tahoe-LAFS version may help")
             if not path:
-                raise PermissionError("cannot STOR to root directory")
-            childname = path[-1]
-            d = self._get_root(path)
-            def _got_root((root, path)):
-                if not path:
-                    raise PermissionError("cannot STOR to root directory")
-                return root.get_child_at_path(path[:-1])
-            d.addCallback(_got_root)
-            def _got_parent(parent):
-                return WriteFile(parent, childname, self.convergence)
-            d.addCallback(_got_parent)
-            return d
-        raise NotImplementedError
+                # case 1
+                if not IFileNode.providedBy(root):
+                    raise SFTPError(FX_PERMISSION_DENIED,
+                                    "cannot open a directory cap")
+                if (flags & FXF_WRITE) and root.is_readonly():
+                    raise SFTPError(FX_PERMISSION_DENIED,
+                                    "cannot write to a non-writeable filecap without a parent directory")
+                if flags & FXF_EXCL:
+                    raise SFTPError(FX_PERMISSION_DENIED,
+                                    "cannot create a file exclusively when it already exists")
+
+                return _make_sftp_file(self.check_abort, flags, self.convergence, filenode=root)
+            else:
+                # case 2
+                childname = path[-1]
+                if debug: print "case 2: childname = %r, path[:-1] = %r" % (childname, path[:-1])
+                d2 = root.get_child_at_path(path[:-1])
+                def _got_parent(parent):
+                    if debug: print "_got_parent(%r)" % (parent,)
+                    stash['parent'] = parent
+
+                    if flags & FXF_EXCL:
+                        # FXF_EXCL means that the link to the file (not the file itself) must
+                        # be created atomically wrt updates by this storage client.
+                        # That is, we need to create the link before returning success to the
+                        # SFTP open request (and not just on close, as would normally be the
+                        # case). We make the link initially point to a zero-length LIT file,
+                        # which is consistent with what might happen on a POSIX filesystem.
+
+                        if parent.is_readonly():
+                            raise SFTPError(FX_PERMISSION_DENIED,
+                                            "cannot create a file exclusively when the parent directory is read-only")
 
-    def removeFile(self, path):
-        print "REMOVEFILE", path
-        path = self._convert_sftp_path(path)
-        return self._remove_thing(path, must_be_file=True)
+                        # 'overwrite=False' ensures failure if the link already exists.
+                        # FIXME: should use a single call to set_uri and return (child, metadata) (#1035)
+                        d3 = parent.set_uri(childname, None, "URI:LIT:", overwrite=False)
+                        def _seturi_done(child):
+                            stash['child'] = child
+                            return parent.get_metadata_for(childname)
+                        d3.addCallback(_seturi_done)
+                        d3.addCallback(lambda metadata: (stash['child'], metadata))
+                        return d3
+                    else:
+                        if debug: print "get_child_and_metadata"
+                        return parent.get_child_and_metadata(childname)
+                d2.addCallback(_got_parent)
+
+                def _got_child( (filenode, metadata) ):
+                    if debug: print "_got_child((%r, %r))" % (filenode, metadata)
+                    parent = stash['parent']
+                    if filenode.is_unknown():
+                        raise SFTPError(FX_PERMISSION_DENIED,
+                                        "cannot open an unknown cap. Upgrading the gateway "
+                                        "to a later Tahoe-LAFS version may help")
+                    if not IFileNode.providedBy(filenode):
+                        raise SFTPError(FX_PERMISSION_DENIED,
+                                        "cannot open a directory as if it were a file")
+                    if (flags & FXF_WRITE) and filenode.is_mutable() and filenode.is_readonly():
+                        raise SFTPError(FX_PERMISSION_DENIED,
+                                        "cannot open a read-only mutable file for writing")
+                    if (flags & FXF_WRITE) and parent.is_readonly():
+                        raise SFTPError(FX_PERMISSION_DENIED,
+                                        "cannot open a file for writing when the parent directory is read-only")
+
+                    return _make_sftp_file(self.check_abort, flags, self.convergence, parent=parent,
+                                           childname=childname, filenode=filenode, metadata=metadata)
+                def _no_child(f):
+                    if debug: print "_no_child(%r)" % (f,)
+                    f.trap(NoSuchChildError)
+                    parent = stash['parent']
+                    if parent is None:
+                        return f
+                    if not (flags & FXF_CREAT):
+                        raise SFTPError(FX_NO_SUCH_FILE,
+                                        "the file does not exist, and was not opened with the creation (CREAT) flag")
+                    if parent.is_readonly():
+                        raise SFTPError(FX_PERMISSION_DENIED,
+                                        "cannot create a file when the parent directory is read-only")
+
+                    return _make_sftp_file(self.check_abort, flags, self.convergence, parent=parent,
+                                           childname=childname)
+                d2.addCallbacks(_got_child, _no_child)
+                return d2
+        d.addCallback(_got_root)
+        d.addErrback(_raise_error)
+        return d
+
+    def removeFile(self, pathstring):
+        if debug: print "REMOVEFILE %r" % (pathstring,)
+        path = self._path_from_string(pathstring)
+        return self._remove_object(path, must_be_file=True)
+
+    def renameFile(self, oldpathstring, newpathstring):
+        if debug: print "RENAMEFILE %r %r" % (oldpathstring, newpathstring)
+        fromPath = self._path_from_string(oldpathstring)
+        toPath = self._path_from_string(newpathstring)
 
-    def renameFile(self, oldpath, newpath):
-        print "RENAMEFILE", oldpath, newpath
-        fromPath = self._convert_sftp_path(oldpath)
-        toPath = self._convert_sftp_path(newpath)
         # the target directory must already exist
-        d = self._get_parent(fromPath)
-        def _got_from_parent( (fromparent, childname) ):
-            d = self._get_parent(toPath)
-            d.addCallback(lambda (toparent, tochildname):
-                          fromparent.move_child_to(childname,
-                                                   toparent, tochildname,
-                                                   overwrite=False))
+        d = deferredutil.gatherResults([self._get_parent(fromPath),
+                                        self._get_parent(toPath)])
+        def _got( (fromPair, toPair) ):
+            (fromParent, fromChildname) = fromPair
+            (toParent, toChildname) = toPair
+
+            # <http://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-6.5>
+            # "It is an error if there already exists a file with the name specified
+            #  by newpath."
+            # FIXME: use move_child_to_path to avoid possible data loss due to #943
+            d = fromParent.move_child_to(fromChildname, toParent, toChildname, overwrite=False)
+            #d = parent.move_child_to_path(fromChildname, toRoot, toPath[:-1],
+            #                              toPath[-1], overwrite=False)
             return d
-        d.addCallback(_got_from_parent)
-        d.addErrback(self._convert_error)
+        d.addCallback(_got)
+        d.addErrback(_raise_error)
         return d
 
-    def makeDirectory(self, path, attrs):
-        print "MAKEDIRECTORY", path, attrs
-        # TODO: extract attrs["mtime"], use it to set the parent metadata.
-        # Maybe also copy attrs["ext_*"] .
-        path = self._convert_sftp_path(path)
+    def makeDirectory(self, pathstring, attrs):
+        if debug: print "MAKEDIRECTORY %r %r" % (pathstring, attrs)
+        path = self._path_from_string(pathstring)
+        metadata = self._attrs_to_metadata(attrs)
         d = self._get_root(path)
         d.addCallback(lambda (root,path):
-                      self._get_or_create_directories(root, path))
+                      self._get_or_create_directories(root, path, metadata))
+        d.addErrback(_raise_error)
         return d
 
-    def _get_or_create_directories(self, node, path):
+    def _get_or_create_directories(self, node, path, metadata):
         if not IDirectoryNode.providedBy(node):
             # unfortunately it is too late to provide the name of the
-            # blocking directory in the error message.
-            raise ExistingChildError("cannot create directory because there "
-                                     "is a file in the way") # close enough
+            # blocking file in the error message.
+            raise SFTPError(FX_PERMISSION_DENIED,
+                            "cannot create directory because there "
+                            "is a file in the way") # close enough
         if not path:
             return defer.succeed(node)
         d = node.get(path[0])
@@ -222,91 +986,148 @@ class SFTPHandler:
             f.trap(NoSuchChildError)
             return node.create_subdirectory(path[0])
         d.addErrback(_maybe_create)
-        d.addCallback(self._get_or_create_directories, path[1:])
+        d.addCallback(self._get_or_create_directories, path[1:], metadata)
+        d.addErrback(_raise_error)
         return d
 
-    def removeDirectory(self, path):
-        print "REMOVEDIRECTORY", path
-        path = self._convert_sftp_path(path)
-        return self._remove_thing(path, must_be_directory=True)
+    def removeDirectory(self, pathstring):
+        if debug: print "REMOVEDIRECTORY %r" % (pathstring,)
+        path = self._path_from_string(pathstring)
+        return self._remove_object(path, must_be_directory=True)
 
-    def _remove_thing(self, path, must_be_directory=False, must_be_file=False):
+    def _remove_object(self, path, must_be_directory=False, must_be_file=False):
         d = defer.maybeDeferred(self._get_parent, path)
-        def _convert_error(f):
-            f.trap(NoParentError)
-            raise PermissionError("cannot delete root directory")
-        d.addErrback(_convert_error)
         def _got_parent( (parent, childname) ):
-            d = parent.get(childname)
+            d2 = parent.get(childname)
             def _got_child(child):
-                if must_be_directory and not IDirectoryNode.providedBy(child):
-                    raise BadRemoveRequest("rmdir called on a file")
+                # Unknown children can be removed by either removeFile or removeDirectory.
+                if must_be_directory and IFileNode.providedBy(child):
+                    raise SFTPError(FX_PERMISSION_DENIED, "rmdir called on a file")
                 if must_be_file and IDirectoryNode.providedBy(child):
-                    raise BadRemoveRequest("rmfile called on a directory")
+                    raise SFTPError(FX_PERMISSION_DENIED, "rmfile called on a directory")
                 return parent.delete(childname)
-            d.addCallback(_got_child)
-            d.addErrback(self._convert_error)
-            return d
+            d2.addCallback(_got_child)
+            return d2
         d.addCallback(_got_parent)
+        d.addErrback(_raise_error)
         return d
 
-
-    def openDirectory(self, path):
-        print "OPENDIRECTORY", path
-        path = self._convert_sftp_path(path)
+    def openDirectory(self, pathstring):
+        if debug: print "OPENDIRECTORY %r" % (pathstring,)
+        path = self._path_from_string(pathstring)
+        if debug: print " PATH %r" % (path,)
         d = self._get_node_and_metadata_for_path(path)
-        d.addCallback(lambda (dirnode,metadata): dirnode.list())
-        def _render(children):
-            results = []
-            for filename, (node, metadata) in children.iteritems():
-                s = FakeStat()
-                if IDirectoryNode.providedBy(node):
-                    s.st_mode = 040700
-                    s.st_size = 0
-                else:
-                    s.st_mode = 0100600
-                    s.st_size = node.get_size()
-                s.st_nlink = 1
-                s.st_uid = 0
-                s.st_gid = 0
-                s.st_mtime = int(metadata.get("mtime", 0))
-                longname = ls.lsLine(filename.encode("utf-8"), s)
-                attrs = self._populate_attrs(node, metadata)
-                results.append( (filename.encode("utf-8"), longname, attrs) )
-            return StoppableList(results)
-        d.addCallback(_render)
+        def _list( (dirnode, metadata) ):
+            if dirnode.is_unknown():
+                raise SFTPError(FX_PERMISSION_DENIED,
+                                "cannot list an unknown cap as a directory. Upgrading the gateway "
+                                "to a later Tahoe-LAFS version may help")
+            if not IDirectoryNode.providedBy(dirnode):
+                raise SFTPError(FX_PERMISSION_DENIED,
+                                "cannot list a file as if it were a directory")
+            d2 = dirnode.list()
+            def _render(children):
+                parent_writeable = not dirnode.is_readonly()
+                results = []
+                for filename, (node, metadata) in children.iteritems():
+                    # The file size may be cached or absent.
+                    writeable = parent_writeable and (node.is_unknown() or
+                                                      not (node.is_mutable() and node.is_readonly()))
+                    attrs = _populate_attrs(node, metadata, writeable)
+                    filename_utf8 = filename.encode('utf-8')
+                    longname = lsLine(filename_utf8, attrs)
+                    results.append( (filename_utf8, longname, attrs) )
+                return StoppableList(results)
+            d2.addCallback(_render)
+            return d2
+        d.addCallback(_list)
+        d.addErrback(_raise_error)
         return d
 
-    def getAttrs(self, path, followLinks):
-        print "GETATTRS", path, followLinks
-        # from ftp.stat
-        d = self._get_node_and_metadata_for_path(self._convert_sftp_path(path))
-        def _render((node,metadata)):
-            return self._populate_attrs(node, metadata)
+    def getAttrs(self, pathstring, followLinks):
+        if debug: print "GETATTRS %r %r" % (pathstring, followLinks)
+        d = self._get_node_and_metadata_for_path(self._path_from_string(pathstring))
+        def _render( (node, metadata) ):
+            # When asked about a specific file, report its current size.
+            # TODO: the modification time for a mutable file should be
+            # reported as the update time of the best version. But that
+            # information isn't currently stored in mutable shares, I think.
+            d2 = node.get_current_size()
+            def _got_size(size):
+                # FIXME: pass correct value for writeable
+                attrs = _populate_attrs(node, metadata, False, size=size)
+                return attrs
+            d2.addCallback(_got_size)
+            return d2
         d.addCallback(_render)
-        d.addErrback(self._convert_error)
+        d.addErrback(_raise_error)
         def _done(res):
-            print " DONE", res
+            if debug: print " DONE %r" % (res,)
             return res
         d.addBoth(_done)
         return d
 
-    def _convert_sftp_path(self, pathstring):
-        assert pathstring[0] == "/"
+    def setAttrs(self, pathstring, attrs):
+        if debug: print "SETATTRS %r %r" % (pathstring, attrs)
+        if "size" in attrs:
+            # this would require us to download and re-upload the truncated/extended
+            # file contents
+            raise SFTPError(FX_OP_UNSUPPORTED, "setAttrs wth size attribute")
+        return None
+
+    def readLink(self, pathstring):
+        if debug: print "READLINK %r" % (pathstring,)
+        raise SFTPError(FX_OP_UNSUPPORTED, "readLink")
+
+    def makeLink(self, linkPathstring, targetPathstring):
+        if debug: print "MAKELINK %r %r" % (linkPathstring, targetPathstring)
+        raise SFTPError(FX_OP_UNSUPPORTED, "makeLink")
+
+    def extendedRequest(self, extendedName, extendedData):
+        if debug: print "EXTENDEDREQUEST %r %r" % (extendedName, extendedData)
+        # Client 'df' command requires 'statvfs@openssh.com' extension
+        # (but there's little point to implementing that since we would only
+        # have faked values to report).
+        raise SFTPError(FX_OP_UNSUPPORTED, "extendedRequest %r" % extendedName)
+
+    def realPath(self, pathstring):
+        if debug: print "REALPATH %r" % (pathstring,)
+        return "/" + "/".join(self._path_from_string(pathstring))
+
+    def _path_from_string(self, pathstring):
+        if debug: print "CONVERT %r" % (pathstring,)
+
+        # The home directory is the root directory.
         pathstring = pathstring.strip("/")
-        if pathstring == "":
-            path = []
+        if pathstring == "" or pathstring == ".":
+            path_utf8 = []
         else:
-            path = pathstring.split("/")
-        print "CONVERT", pathstring, path
-        path = [unicode(p) for p in path]
+            path_utf8 = pathstring.split("/")
+
+        # <http://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-6.2>
+        # "Servers SHOULD interpret a path name component ".." as referring to\r
+        #  the parent directory, and "." as referring to the current directory."\r
+        path = []
+        for p_utf8 in path_utf8:
+            if p_utf8 == "..":
+                # ignore excess .. components at the root
+                if len(path) > 0:
+                    path = path[:-1]
+            elif p_utf8 != ".":
+                try:
+                    p = p_utf8.decode('utf-8', 'strict')
+                except UnicodeError:
+                    raise SFTPError(FX_NO_SUCH_FILE, "path could not be decoded as UTF-8")
+                path.append(p)
+
+        if debug: print " PATH %r" % (path,)
         return path
 
     def _get_node_and_metadata_for_path(self, path):
         d = self._get_root(path)
-        def _got_root((root,path)):
-            print "ROOT", root
-            print "PATH", path
+        def _got_root( (root, path) ):
+            if debug: print " ROOT %r" % (root,)
+            if debug: print " PATH %r" % (path,)
             if path:
                 return root.get_child_and_metadata_at_path(path)
             else:
@@ -316,8 +1137,7 @@ class SFTPHandler:
 
     def _get_root(self, path):
         # return (root, remaining_path)
-        path = [unicode(p) for p in path]
-        if path and path[0] == "uri":
+        if path and path[0] == u"uri":
             d = defer.maybeDeferred(self.client.create_node_from_uri,
                                     str(path[1]))
             d.addCallback(lambda root: (root, path[2:]))
@@ -325,71 +1145,18 @@ class SFTPHandler:
             d = defer.succeed((self.root,path))
         return d
 
-    def _populate_attrs(self, childnode, metadata):
-        attrs = {}
-        attrs["uid"] = 1000
-        attrs["gid"] = 1000
-        attrs["atime"] = 0
-        attrs["mtime"] = int(metadata.get("mtime", 0))
-        isdir = bool(IDirectoryNode.providedBy(childnode))
-        if isdir:
-            attrs["size"] = 1
-            # the permissions must have the extra bits (040000 or 0100000),
-            # otherwise the client will not call openDirectory
-            attrs["permissions"] = 040700 # S_IFDIR
-        else:
-            attrs["size"] = childnode.get_size()
-            attrs["permissions"] = 0100600 # S_IFREG
-        return attrs
-
-    def _convert_error(self, f):
-        if f.check(NoSuchChildError):
-            childname = f.value.args[0].encode("utf-8")
-            raise SFTPError(FX_NO_SUCH_FILE, childname)
-        if f.check(ExistingChildError):
-            msg = f.value.args[0].encode("utf-8")
-            raise SFTPError(FX_FILE_ALREADY_EXISTS, msg)
-        if f.check(PermissionError):
-            raise SFTPError(FX_PERMISSION_DENIED, str(f.value))
-        if f.check(NotImplementedError):
-            raise SFTPError(FX_OP_UNSUPPORTED, str(f.value))
-        return f
-
-
-    def setAttrs(self, path, attrs):
-        print "SETATTRS", path, attrs
-        # ignored
-        return None
-
-    def readLink(self, path):
-        print "READLINK", path
-        raise NotImplementedError
-
-    def makeLink(self, linkPath, targetPath):
-        print "MAKELINK", linkPath, targetPath
-        raise NotImplementedError
-
-    def extendedRequest(self, extendedName, extendedData):
-        print "EXTENDEDREQUEST", extendedName, extendedData
-        # client 'df' command requires 'statvfs@openssh.com' extension
-        raise NotImplementedError
-    def realPath(self, path):
-        print "REALPATH", path
-        if path == ".":
-            return "/"
-        return path
-
-
     def _get_parent(self, path):
         # fire with (parentnode, childname)
-        path = [unicode(p) for p in path]
         if not path:
-            raise NoParentError
+            def _nosuch(): raise SFTPError(FX_NO_SUCH_FILE, "path does not exist")
+            return defer.execute(_nosuch)
+
         childname = path[-1]
+        assert isinstance(childname, unicode), repr(childname)
         d = self._get_root(path)
-        def _got_root((root, path)):
+        def _got_root( (root, path) ):
             if not path:
-                raise NoParentError
+                raise SFTPError(FX_NO_SUCH_FILE, "path does not exist")
             return root.get_child_at_path(path[:-1])
         d.addCallback(_got_root)
         def _got_parent(parent):
@@ -397,12 +1164,23 @@ class SFTPHandler:
         d.addCallback(_got_parent)
         return d
 
+    def _attrs_to_metadata(self, attrs):
+        metadata = {}
+
+        for key in attrs:
+            if key == "mtime" or key == "ctime" or key == "createtime":
+                metadata[key] = long(attrs[key])
+            elif key.startswith("ext_"):
+                metadata[key] = str(attrs[key])
+
+        return metadata
+
 
 # if you have an SFTPUser, and you want something that provides ISFTPServer,
 # then you get SFTPHandler(user)
 components.registerAdapter(SFTPHandler, SFTPUser, ISFTPServer)
 
-from allmydata.frontends.auth import AccountURLChecker, AccountFileChecker, NeedRootcapLookupScheme
+from auth import AccountURLChecker, AccountFileChecker, NeedRootcapLookupScheme
 
 class Dispatcher:
     implements(portal.IRealm)
@@ -413,8 +1191,12 @@ class Dispatcher:
         assert interface == IConchUser
         rootnode = self.client.create_node_from_uri(avatarID.rootcap)
         convergence = self.client.convergence
-        s = SFTPUser(self.client, rootnode, avatarID.username, convergence)
-        def logout(): pass
+        logged_out = {'flag': False}
+        def check_abort():
+            return logged_out['flag']
+        def logout():
+            logged_out['flag'] = True
+        s = SFTPUser(check_abort, self.client, rootnode, avatarID.username, convergence)
         return (interface, s, logout)
 
 class SFTPServer(service.MultiService):