Add encrypted S3 remote move

ober

52bb91bd24c100191bf971c174f0f1749809894c

diff --git a/README.md b/README.md
index 9028fb3..17eb393 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ jdrive s3 cp -r ./backup-dir /backups
 jdrive s3 cp /backups/notes.txt ./notes-restored.txt
 jdrive s3 cp -r /backups ./restored-backups
 jdrive s3 cp -r /backups /archive
+jdrive s3 mv /archive/backups/notes.txt /archive/notes.txt
 jdrive s3 sync ./backup-dir /backups --delete
 jdrive s3 check ./backup-dir /backups
 jdrive s3 ls /backups
diff --git a/docs/jerboa-drive-plan.md b/docs/jerboa-drive-plan.md
index 3df04dc..4ffdbad 100644
--- a/docs/jerboa-drive-plan.md
+++ b/docs/jerboa-drive-plan.md
@@ -96,6 +96,7 @@ Completed so far:
   - `jdrive s3 unlock-test`
   - `jdrive s3 mkdir`
   - `jdrive s3 cp [-r]`
+  - `jdrive s3 mv [-r]`
   - `jdrive s3 sync`
   - `jdrive s3 check`
   - `jdrive s3 ls`
@@ -113,6 +114,7 @@ Completed so far:
   - recursive local-to-remote upload
   - recursive remote-to-local copy
   - recursive remote-to-remote copy with re-encryption for destination paths
+  - remote move/rename through encrypted copy-then-remove
   - local-to-remote sync
   - local-vs-remote check
   - simple `*` include/exclude filters
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 3fb6937..e58ca93 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -131,6 +131,7 @@ jdrive s3 cp -r ./backup-dir /backups
 jdrive s3 cp /backups/notes.txt ./notes-restored.txt
 jdrive s3 cp -r /backups ./restored-backups
 jdrive s3 cp -r /backups /archive
+jdrive s3 mv /archive/backups/notes.txt /archive/notes.txt
 jdrive s3 sync ./backup-dir /backups --delete
 jdrive s3 check ./backup-dir /backups
 jdrive s3 ls /backups
diff --git a/protonstorage/cli.ss b/protonstorage/cli.ss
index 4a751b7..bd0ad1f 100644
--- a/protonstorage/cli.ss
+++ b/protonstorage/cli.ss
@@ -47,6 +47,7 @@
       "  s3 status                      Show S3 backend configuration\n"
       "  s3 init --bucket B             Create encrypted S3 drive profile\n"
       "  s3 cp [-r] SRC DST             Copy to/from encrypted S3 drive\n"
+      "  s3 mv [-r] REMOTE DST          Move encrypted S3 file/tree\n"
       "  s3 ls [REMOTE]                 List encrypted S3 drive manifest\n"
       "  doctor                         Show implementation status\n"
       "\n"
@@ -80,6 +81,7 @@
       "  s3 profile-status              Show local encrypted S3 profile state\n"
       "  s3 unlock-test                 Verify S3 profile password/YubiKey unlock\n"
       "  s3 cp [-r] SRC DST             Copy local/remote file/tree through encrypted S3\n"
+      "  s3 mv [-r] REMOTE DST          Move encrypted S3 file/tree\n"
       "  s3 sync LOCAL REMOTE           Sync local tree to encrypted S3\n"
       "  s3 check LOCAL REMOTE          Compare local tree to encrypted S3\n"
       "  s3 ls [REMOTE]                 List encrypted S3 paths\n"
@@ -1333,6 +1335,28 @@
                          opts
                          "--chunk-size"
                          jdrive-s3-default-chunk-size))))]
+                [(or (string=? action "mv") (string=? action "move"))
+                 (unless (= (length pos) 2)
+                   (die 2 "usage: jdrive s3 mv [-r] REMOTE_PATH REMOTE_PATH"))
+                 (let* ([password (s3-vault-password-from-opts opts)]
+                        [material (s3-yubikey-material-for-profile opts)]
+                        [source (car pos)]
+                        [destination (cadr pos)])
+                   (unless (and (remote-path? source) (remote-path? destination))
+                     (die 2 "s3 mv requires remote paths beginning with /"))
+                   (print-json
+                     (jdrive-s3-move!
+                       (profile-option opts)
+                       (state-root-option opts)
+                       password
+                       material
+                       source
+                       destination
+                       (recursive-upload? opts)
+                       (option-number
+                         opts
+                         "--chunk-size"
+                         jdrive-s3-default-chunk-size))))]
                 [(string=? action "cat")
                  (unless (= (length pos) 1)
                    (die 2 "usage: jdrive s3 cat REMOTE_PATH"))
diff --git a/protonstorage/s3/drive.ss b/protonstorage/s3/drive.ss
index 0b2a6af..9381b27 100644
--- a/protonstorage/s3/drive.ss
+++ b/protonstorage/s3/drive.ss
@@ -26,6 +26,7 @@
     jdrive-s3-check-local-path!
     jdrive-s3-sync-local-to-remote!
     jdrive-s3-copy!
+    jdrive-s3-move!
     jdrive-s3-cat-file-bytes
     jdrive-s3-cat-file-range-bytes
     jdrive-s3-get-file!
@@ -1786,6 +1787,49 @@
               source
               destination)]))
 
+  (define (jdrive-s3-move!
+           profile state-root vault-password yubikey-material source destination
+           recursive? chunk-size)
+    (let ([source-path (remote-path-normalize source)]
+          [destination-path (remote-path-normalize destination)])
+      (when (string=? source-path destination-path)
+        (error 'jdrive-s3-move! "source and destination are the same" source-path))
+      (when (under-prefix? source-path destination-path)
+        (error 'jdrive-s3-move!
+               "refusing to move a remote tree into itself"
+               source-path
+               destination-path))
+      (let ([copy-summary
+             (jdrive-s3-copy!
+               profile
+               state-root
+               vault-password
+               yubikey-material
+               source-path
+               destination-path
+               #t
+               #t
+               recursive?
+               chunk-size)])
+        (let ([remove-summary
+               (jdrive-s3-remove!
+                 profile
+                 state-root
+                 vault-password
+                 yubikey-material
+                 source-path
+                 recursive?)])
+          (json-object
+            "Backend" "s3"
+            "Profile" (jdrive-s3-profile-name profile)
+            "Source" source-path
+            "Destination" destination-path
+            "Recursive" (if recursive? #t #f)
+            "MovedFiles" (hashtable-ref copy-summary "CopiedFiles" 0)
+            "CopiedBytes" (hashtable-ref copy-summary "CopiedBytes" 0)
+            "CopiedChunks" (hashtable-ref copy-summary "CopiedChunks" 0)
+            "RemovedFiles" (hashtable-ref remove-summary "RemovedFiles" 0))))))
+
   (define (jdrive-s3-cat-file-bytes profile state-root vault-password yubikey-material remote-path)
     (jdrive-s3-cat-file-range-bytes
       profile
diff --git a/test/integration-s3.ss b/test/integration-s3.ss
index 1a2a6ba..62c6bfb 100644
--- a/test/integration-s3.ss
+++ b/test/integration-s3.ss
@@ -237,6 +237,21 @@
            0
            -1)))
 
+(jdrive-s3-move!
+  profile state-root vault-password #vu8()
+  "/copy/a.txt"
+  "/moved/a.txt"
+  #f
+  chunk-size)
+(check "remote file move decrypts"
+       (bytevector=?
+         (string->utf8 "alpha plaintext")
+         (jdrive-s3-cat-file-range-bytes
+           profile state-root vault-password #vu8()
+           "/moved/a.txt"
+           0
+           -1)))
+
 (jdrive-s3-remove!
   profile state-root vault-password #vu8()
   "/"
diff --git a/test/test-all.ss b/test/test-all.ss
index 7b4dfec..9759016 100644
--- a/test/test-all.ss
+++ b/test/test-all.ss
@@ -120,6 +120,7 @@
             (test-string-contains? usage-string "s3 target --bucket B --key K")
             (test-string-contains? usage-string "s3 init --bucket B")
             (test-string-contains? usage-string "s3 cp [-r] SRC DST")
+            (test-string-contains? usage-string "s3 mv [-r] REMOTE DST")
             (test-string-contains? usage-string "s3 sync LOCAL REMOTE")
             (test-string-contains? usage-string "s3 check LOCAL REMOTE")
             (test-string-contains? usage-string "s3 mkdir REMOTE")