diff --git a/docs/frontends/webapi.rst b/docs/frontends/webapi.rst
index 47ab754..a715a50 100644
--- a/docs/frontends/webapi.rst
+++ b/docs/frontends/webapi.rst
@@ -29,8 +29,9 @@ The Tahoe REST-ful Web API
     6.  `Attaching An Existing File Or Directory (by URI)`_
     7.  `Unlinking A Child`_
     8.  `Renaming A Child`_
-    9.  `Other Utilities`_
-    10. `Debugging and Testing Features`_
+    9.  `Moving A Child`_
+    10. `Other Utilities`_
+    11. `Debugging and Testing Features`_
 
 7.  `Other Useful Pages`_
 8.  `Static Files in /public_html`_
@@ -1273,6 +1274,21 @@ Renaming A Child
  This operation will replace any existing child of the new name, making it
  behave like the UNIX "``mv -f``" command.
 
+Moving A Child
+----------------
+
+``POST /uri/$DIRCAP/[SUBDIRS../]?t=rename&from_name=OLD&to_dir=TARGET[&to_name=NEW]``
+
+ This instructs the node to move a child of the given directory to a
+ different directory, both of which must be mutable. The child can also be
+ renamed in the process. The to_dir parameter can be either the name of a
+ subdirectory of the dircap from which the child is being moved (multiple
+ levels of descent are supported) or the writecap of an unrelated directory.
+
+ This operation will replace any existing child of the new name, making it
+ behave like the UNIX "``mv -f``" command. The original child is not
+ unlinked until it is linked into the target directory.
+
 Other Utilities
 ---------------
 
@@ -1294,6 +1310,8 @@ Other Utilities
   functionality described above, with the provided $CHILDNAME present in the
   'from_name' field of that form. I.e. this presents a form offering to
   rename $CHILDNAME, requesting the new name, and submitting POST rename.
+  This same URL format can also be used with "move-form" with the expected
+  results.
 
 ``GET /uri/$DIRCAP/[SUBDIRS../]CHILDNAME?t=uri``
 
diff --git a/src/allmydata/test/test_web.py b/src/allmydata/test/test_web.py
index dc7ab9e..0913f0d 100644
--- a/src/allmydata/test/test_web.py
+++ b/src/allmydata/test/test_web.py
@@ -236,6 +236,7 @@ class WebMixin(object):
             self._sub_uri = sub_uri
             foo.set_uri(u"sub", sub_uri, sub_uri)
             sub = self.s.create_node_from_uri(sub_uri)
+            self._sub_node = sub
 
             _ign, n, blocking_uri = self.makefile(1)
             foo.set_uri(u"blockingfile", blocking_uri, blocking_uri)
@@ -245,7 +246,7 @@ class WebMixin(object):
             # still think of it as an umlaut
             foo.set_uri(unicode_filename, self._bar_txt_uri, self._bar_txt_uri)
 
-            _ign, n, baz_file = self.makefile(2)
+            self.SUBBAZ_CONTENTS, n, baz_file = self.makefile(2)
             self._baz_file_uri = baz_file
             sub.set_uri(u"baz.txt", baz_file, baz_file)
 
@@ -300,6 +301,9 @@ class WebMixin(object):
     def failUnlessIsBazDotTxt(self, res):
         self.failUnlessReallyEqual(res, self.BAZ_CONTENTS, res)
 
+    def failUnlessIsSubBazDotTxt(self, res):
+        self.failUnlessReallyEqual(res, self.SUBBAZ_CONTENTS, res)
+
     def failUnlessIsBarJSON(self, res):
         data = simplejson.loads(res)
         self.failUnless(isinstance(data, list))
@@ -1335,7 +1339,7 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
                                r'\s+<td align="right">%d</td>' % len(self.BAR_CONTENTS),
                                ])
             self.failUnless(re.search(get_bar, res), res)
-            for label in ['unlink', 'rename']:
+            for label in ['unlink', 'rename', 'move']:
                 for line in res.split("\n"):
                     # find the line that contains the relevant button for bar.txt
                     if ("form action" in line and
@@ -3316,6 +3320,151 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
         d.addCallback(self.failUnlessIsFooJSON)
         return d
 
+    def test_POST_move_file(self):
+        """"""
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_dir="sub")
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res:
+                      self.failUnlessNodeHasChild(self._sub_node, u"bar.txt"))
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_new_name(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_name="wibble.txt", to_dir="sub")
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
+        d.addCallback(lambda res:
+                      self.failUnlessNodeHasChild(self._sub_node, u"wibble.txt"))
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_replace(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_name="baz.txt", to_dir="sub")
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_no_replace(self):
+        d = self.POST(self.public_url + "/foo", t="move", replace="false",
+                      from_name="bar.txt", to_name="baz.txt", to_dir="sub")
+        d.addBoth(self.shouldFail, error.Error,
+                  "POST_move_file_no_replace",
+                  "409 Conflict",
+                  "There was already a child by that name, and you asked me "
+                  "to not replace it")
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
+        d.addCallback(self.failUnlessIsSubBazDotTxt)
+        return d
+
+    def test_POST_move_file_slash_fail(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_name="slash/fail.txt", to_dir="sub")
+        d.addBoth(self.shouldFail, error.Error,
+                  "test_POST_rename_file_slash_fail",
+                  "400 Bad Request",
+                  "to_name= may not contain a slash",
+                  )
+        d.addCallback(lambda res:
+                      self.failUnlessNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._sub_node, u"slash/fail.txt"))
+        return d
+
+    def test_POST_move_file_no_target(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_name="baz.txt")
+        d.addBoth(self.shouldFail, error.Error,
+                  "POST_move_file_no_target",
+                  "400 Bad Request",
+                  "move requires from_name and to_dir")
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
+        d.addCallback(self.failUnlessIsBazDotTxt)
+        return d
+
+    def test_POST_move_file_multi_level(self):
+        d = self.POST(self.public_url + "/foo/sub/level2?t=mkdir", "")
+        d.addCallback(lambda res: self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_dir="sub/level2"))
+        d.addCallback(lambda res: self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res: self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_to_uri(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_dir=self._sub_uri)
+        d.addCallback(lambda res:
+                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_to_nonexist_dir(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_dir="notchucktesta")
+        d.addBoth(self.shouldFail, error.Error,
+                  "POST_move_file_to_nonexist_dir",
+                  "404 Not Found",
+                  "No such child: notchucktesta")
+        return d
+
+    def test_POST_move_file_into_file(self):
+        d = self.POST(self.public_url + "/foo", t="move",
+                      from_name="bar.txt", to_dir="baz.txt")
+        d.addBoth(self.shouldFail, error.Error,
+                  "POST_move_file_into_file",
+                  "410 Gone",
+                  "to_dir is not a usable directory")
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
+        d.addCallback(self.failUnlessIsBazDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
+    def test_POST_move_file_to_bad_uri(self):
+        d = self.POST(self.public_url + "/foo", t="move", from_name="bar.txt",
+                      to_dir="URI:DIR2:mn5jlyjnrjeuydyswlzyui72i:rmneifcj6k6sycjljjhj3f6majsq2zqffydnnul5hfa4j577arma")
+        d.addBoth(self.shouldFail, error.Error,
+                  "POST_move_file_to_bad_uri",
+                  "410 Gone",
+                  "to_dir is not a usable directory")
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
+        d.addCallback(self.failUnlessIsBarDotTxt)
+        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
+        d.addCallback(self.failUnlessIsBarJSON)
+        return d
+
     def shouldRedirect(self, res, target=None, statuscode=None, which=""):
         """ If target is not None then the redirection has to go to target.  If
         statuscode is not None then the redirection has to be accomplished with
@@ -3372,6 +3521,15 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
         d.addCallback(_check)
         return d
 
+    def test_GET_move_form(self):
+        d = self.GET(self.public_url + "/foo?t=move-form&name=bar.txt",
+                     followRedirect=True)
+        def _check(res):
+            self.failUnless('name="when_done" value="."' in res, res)
+            self.failUnless(re.search(r'name="from_name" value="bar\.txt"', res))
+        d.addCallback(_check)
+        return d
+
     def log(self, res, msg):
         #print "MSG: %s  RES: %s" % (msg, res)
         log.msg(msg)
diff --git a/src/allmydata/uri.py b/src/allmydata/uri.py
index 8cb9d10..e2b78a2 100644
--- a/src/allmydata/uri.py
+++ b/src/allmydata/uri.py
@@ -934,6 +934,13 @@ def is_literal_file_uri(s):
             s.startswith(ALLEGED_READONLY_PREFIX + 'URI:LIT:') or
             s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:LIT:'))
 
+def is_writeable_directory_uri(s):
+    if not isinstance(s, str):
+        return False
+    return (s.startswith('URI:DIR2:') or
+            s.startswith(ALLEGED_READONLY_PREFIX + 'URI:DIR2:') or
+            s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:DIR2:'))
+
 def has_uri_prefix(s):
     if not isinstance(s, str):
         return False
diff --git a/src/allmydata/web/directory.py b/src/allmydata/web/directory.py
index 58fe781..90ef36a 100644
--- a/src/allmydata/web/directory.py
+++ b/src/allmydata/web/directory.py
@@ -13,7 +13,7 @@ from nevow.inevow import IRequest
 from foolscap.api import fireEventually
 
 from allmydata.util import base32, time_format
-from allmydata.uri import from_string_dirnode
+from allmydata.uri import from_string_dirnode, is_writeable_directory_uri
 from allmydata.interfaces import IDirectoryNode, IFileNode, IFilesystemNode, \
      IImmutableFileNode, IMutableFileNode, ExistingChildError, \
      NoSuchChildError, EmptyPathnameComponentError, SDMF_VERSION, MDMF_VERSION
@@ -169,6 +169,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
             return DirectoryReadonlyURI(ctx, self.node)
         if t == 'rename-form':
             return RenameForm(self.node)
+        if t == 'move-form':
+            return MoveForm(self.node)
 
         raise WebError("GET directory: bad t=%s" % t)
 
@@ -213,6 +215,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
             d = self._POST_unlink(req)
         elif t == "rename":
             d = self._POST_rename(req)
+        elif t == "move":
+            d = self._POST_move(req)
         elif t == "check":
             d = self._POST_check(req)
         elif t == "start-deep-check":
@@ -418,6 +422,52 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
         d.addCallback(lambda res: "thing renamed")
         return d
 
+    def _POST_move(self, req):
+        charset = get_arg(req, "_charset", "utf-8")
+        from_name = get_arg(req, "from_name")
+        if from_name is not None:
+            from_name = from_name.strip()
+            from_name = from_name.decode(charset)
+            assert isinstance(from_name, unicode)
+        to_name = get_arg(req, "to_name")
+        if to_name is not None:
+            to_name = to_name.strip()
+            to_name = to_name.decode(charset)
+            assert isinstance(to_name, unicode)
+        if not to_name:
+            to_name = from_name
+        to_dir = get_arg(req, "to_dir")
+        if to_dir is not None:
+            to_dir = to_dir.strip()
+            to_dir = to_dir.decode(charset)
+            assert isinstance(to_dir, unicode)
+        if not from_name or not to_dir:
+            raise WebError("move requires from_name and to_dir")
+        replace = boolean_of_arg(get_arg(req, "replace", "true"))
+
+        # allow from_name to contain slashes, so they can fix names that
+        # were accidentally created with them. But disallow them in to_name
+        # (if it's specified), to discourage the practice.
+        if to_name and "/" in to_name:
+            raise WebError("to_name= may not contain a slash", http.BAD_REQUEST)
+
+        d = self.node.has_child(to_dir.split('/')[0])
+        def get_target_node(isname):
+            if isname or not is_writeable_directory_uri(str(to_dir)):
+                return self.node.get_child_at_path(to_dir)
+            else:
+                return self.client.create_node_from_uri(str(to_dir))
+        d.addCallback(get_target_node)
+        def is_target_node_usable(target_node):
+            if not IDirectoryNode.providedBy(target_node):
+                raise WebError("to_dir is not a usable directory", http.GONE)
+            return target_node
+        d.addCallback(is_target_node_usable)
+        d.addCallback(lambda new_parent: self.node.move_child_to(
+                      from_name, new_parent, to_name, replace))
+        d.addCallback(lambda res: "thing moved")
+        return d
+
     def _maybe_literal(self, res, Results_Class):
         if res:
             return Results_Class(self.client, res)
@@ -662,6 +712,7 @@ class DirectoryAsHTML(rend.Page):
         if self.node.is_unknown() or self.node.is_readonly():
             unlink = "-"
             rename = "-"
+            move = "-"
         else:
             # this creates a button which will cause our _POST_unlink method
             # to be invoked, which unlinks the file and then redirects the
@@ -680,8 +731,16 @@ class DirectoryAsHTML(rend.Page):
                 T.input(type='submit', value='rename', name="rename"),
                 ]
 
+            move = T.form(action=here, method="get")[
+                T.input(type='hidden', name='t', value='move-form'),
+                T.input(type='hidden', name='name', value=name),
+                T.input(type='hidden', name='when_done', value="."),
+                T.input(type='submit', value='move', name="move"),
+                ]
+
         ctx.fillSlots("unlink", unlink)
         ctx.fillSlots("rename", rename)
+        ctx.fillSlots("move", move)
 
         times = []
         linkcrtime = metadata.get('tahoe', {}).get("linkcrtime")
@@ -943,6 +1002,32 @@ class RenameForm(rend.Page):
         ctx.tag.attributes['value'] = name
         return ctx.tag
 
+class MoveForm(rend.Page):
+    addSlash = True
+    docFactory = getxmlfile("move-form.xhtml")
+
+    def render_title(self, ctx, data):
+        return ctx.tag["Directory SI=%s" % abbreviated_dirnode(self.original)]
+
+    def render_header(self, ctx, data):
+        header = ["Move "
+                  "from directory SI=%s" % abbreviated_dirnode(self.original),
+                  ]
+
+        if self.original.is_readonly():
+            header.append(" (readonly!)")
+        header.append(":")
+        return ctx.tag[header]
+
+    def render_when_done(self, ctx, data):
+        return T.input(type="hidden", name="when_done", value=".")
+
+    def render_get_name(self, ctx, data):
+        req = IRequest(ctx)
+        name = get_arg(req, "name", "")
+        ctx.tag.attributes['value'] = name
+        return ctx.tag
+
 
 class ManifestResults(rend.Page, ReloadMixin):
     docFactory = getxmlfile("manifest.xhtml")
diff --git a/src/allmydata/web/directory.xhtml b/src/allmydata/web/directory.xhtml
index 4875738..1df38fe 100644
--- a/src/allmydata/web/directory.xhtml
+++ b/src/allmydata/web/directory.xhtml
@@ -33,6 +33,7 @@
       <td><n:slot name="times"/></td>
       <td><n:slot name="unlink"/></td>
       <td><n:slot name="rename"/></td>
+      <td><n:slot name="move"/></td>
       <td><n:slot name="info"/></td>
     </tr>
 
diff --git a/src/allmydata/web/move-form.xhtml b/src/allmydata/web/move-form.xhtml
new file mode 100644
index 0000000..0460add
--- /dev/null
+++ b/src/allmydata/web/move-form.xhtml
@@ -0,0 +1,31 @@
+<html xmlns:n="http://nevow.com/ns/nevow/0.1">
+  <head>
+    <title n:render="title"></title>
+    <link href="/tahoe_css" rel="stylesheet" type="text/css"/>
+    <link href="/webform_css" rel="stylesheet" type="text/css"/>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+  </head>
+
+<body>
+
+<h2 n:render="header" />
+
+<div class="freeform-form">
+    <form action="." method="post" enctype="multipart/form-data">
+        <fieldset>
+            <legend class="freeform-form-label">Rename child</legend>
+            <input type="hidden" name="t" value="move" />
+            <input n:render="when_done" />
+
+            Move child:
+            <input type="text" name="from_name" readonly="true" n:render="get_name" />
+            to
+            <input type="text" name="to_dir" /><br />
+            New name?
+            <input type="text" name="to_name" />
+            <input type="submit" value="move" />
+        </fieldset>
+    </form>
+</div>
+
+</body></html>
