Skip to content

Commit 7d69ea6

Browse files
committed
Add scp-from and scp-to functions.
Copy files to and from the remote host using SCP. Fixes: #3
1 parent 21e614e commit 7d69ea6

File tree

3 files changed

+311
-23
lines changed

3 files changed

+311
-23
lines changed

src/clj_libssh2/channel.clj

Lines changed: 137 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
(ns clj-libssh2.channel
22
"Functions for manipulating channels within an SSH session."
3+
(:refer-clojure :exclude [read])
34
(:require [net.n01se.clojure-jna :as jna]
45
[clj-libssh2.error :refer [handle-errors]]
56
[clj-libssh2.libssh2 :as libssh2]
67
[clj-libssh2.libssh2.channel :as libssh2-channel]
8+
[clj-libssh2.libssh2.scp :as libssh2-scp]
79
[clj-libssh2.socket :refer [block block-return wait]])
810
(:import [java.io InputStream PushbackInputStream]
9-
[com.sun.jna.ptr IntByReference PointerByReference]))
11+
[com.sun.jna.ptr IntByReference PointerByReference]
12+
[clj_libssh2.struct Stat]))
1013

1114
(defn close
1215
"Close a channel.
@@ -124,6 +127,49 @@
124127
[session]
125128
(block-return session (libssh2-channel/open-session (:session session))))
126129

130+
(defn open-scp-recv
131+
"Create a new channel dedicated to receiving a file using SCP.
132+
133+
Arguments:
134+
135+
session The clj-libssh2.session.Session object for the current session.
136+
remote-path The path on the remote machine of the file to receive.
137+
138+
Return:
139+
140+
A map containing a newly-allocated channel object and the Stat object for
141+
the file information as reported by the remote host. Throws exception on
142+
failure."
143+
[session remote-path]
144+
(let [fileinfo (Stat/newInstance)]
145+
{:channel (block-return session
146+
(libssh2-scp/recv (:session session) remote-path fileinfo))
147+
:fileinfo fileinfo}))
148+
149+
(defn open-scp-send
150+
"Create a new channel dedicated to sending a file using SCP.
151+
152+
Arguments:
153+
154+
session The clj-libssh2.session.Session object for the current session.
155+
remote-path The path on the remote machine of the file to send.
156+
props A map with the following keys and values:
157+
158+
:atime The last accessed time to be set on the remote file.
159+
:mode The mode that the remote file should have.
160+
:mtime The last modified time to be set on the remote file.
161+
:size The size of the file in bytes.
162+
163+
Return:
164+
165+
A newly-allocated channel object for sending a file via SCP."
166+
[session remote-path {:keys [atime mode mtime size] :as props}]
167+
(let [mode (or mode 0644)
168+
mtime (or mtime 0)
169+
atime (or atime 0)]
170+
(block-return session
171+
(libssh2-scp/send64 (:session session) remote-path mode size mtime atime))))
172+
127173
(defn send-eof
128174
"Tell the remote process that we won't send any more input.
129175
@@ -173,11 +219,44 @@
173219
(fail-if-forbidden
174220
(libssh2-channel/setenv channel (->str k) (->str v))))))))
175221

222+
(defn read
223+
"Attempt to read a chunk of data from a stream on a channel.
224+
225+
Arguments:
226+
227+
session The clj-libssh2.session.Session object for the current
228+
session.
229+
channel A valid channel for this session.
230+
ssh-stream-id 0 for STDOUT, 1 for STDERR or any other number that the
231+
process on the other end wishes to send data on.
232+
size The number of bytes to request.
233+
234+
Return:
235+
236+
A map with the following keys and values:
237+
238+
:status One of :eof, :ready or :eagain depending on whether the stream
239+
has reported EOF, has potentially more bytes to read immediately
240+
or has no bytes to read right now but might in the future.
241+
:received The number of bytes received.
242+
:data A byte array containing the data which was received."
243+
[session channel ssh-stream-id size]
244+
(let [buf1 (jna/make-cbuf size)
245+
res (handle-errors session
246+
(libssh2-channel/read-ex channel ssh-stream-id buf1 size))]
247+
(if (< 0 res)
248+
(let [buf2 (byte-array res (byte 0))]
249+
(.get buf1 buf2 0 res)
250+
{:status :ready
251+
:received res
252+
:data buf2})
253+
{:status (if (= libssh2/ERROR_EAGAIN res) :eagain :eof)
254+
:received 0
255+
:data nil})))
256+
176257
(defn pull
177258
"Read some output from a given stream on a channel.
178259
179-
This should probably only be called from pump.
180-
181260
Arguments:
182261
183262
session The clj-libssh2.session.Session object for the current
@@ -195,23 +274,15 @@
195274
read immediately."
196275
[session channel ssh-stream-id output-stream]
197276
(let [size (-> session :options :read-chunk-size)
198-
buf1 (jna/make-cbuf size)
199-
res (handle-errors session
200-
(libssh2-channel/read-ex channel ssh-stream-id buf1 size))]
201-
(when (and (some? output-stream) (< 0 res))
202-
(let [buf2 (byte-array size (byte 0))]
203-
(.get buf1 buf2 0 res)
204-
(.write output-stream buf2 0 res)))
205-
(condp = res
206-
0 :eof
207-
libssh2/ERROR_EAGAIN :eagain
208-
:ready)))
277+
res (read session channel ssh-stream-id size)
278+
{status :status received :received data :data} res]
279+
(when (and (some? output-stream) (< 0 received))
280+
(.write output-stream data 0 received))
281+
status))
209282

210283
(defn push
211284
"Write some input to a given stream on a channel.
212285
213-
This should probably only be called from pump.
214-
215286
Arguments:
216287
217288
session The clj-libssh2.session.Session object for the current
@@ -384,9 +455,56 @@
384455
session The clj-libssh2.session.Session object for the current session.
385456
channel This will be bound to the result of a call to open."
386457
[session channel & body]
387-
`(let [~channel (open ~session)]
458+
`(let [session# ~session
459+
~channel (open session#)]
460+
(try
461+
(do ~@body)
462+
(finally
463+
(close session# ~channel)
464+
(free session# ~channel)))))
465+
466+
(defmacro with-scp-recv-channel
467+
"A convenience macro like with-channel, but for SCP receive operations.
468+
469+
Arguments:
470+
471+
session The clj-libssh2.session.Session object for the current session.
472+
channel This will be bound to the channel when it's opened.
473+
path The path of the file to receive from the remote machine.
474+
fileinfo This will be bound to the Stat struct describing the properties of
475+
the remote file."
476+
[session channel path fileinfo & body]
477+
`(let [session# ~session
478+
{~channel :channel ~fileinfo :fileinfo} (open-scp-recv session# ~path)]
479+
(try
480+
(do ~@body)
481+
(finally
482+
(close session# ~channel)
483+
(free session# ~channel)))))
484+
485+
(defmacro with-scp-send-channel
486+
"A convenience macro like with-channel, but for sending files using SCP.
487+
488+
Arguments:
489+
490+
session The clj-libssh2.session.Session object for the current session.
491+
channel This will be bound to the channel when it's opened.
492+
path The path where the file should be places on the remote machine.
493+
props A map describing the properties which should be applied to the
494+
file when it's transferred to the other side. The keys and values
495+
are as follows:
496+
497+
:atime The last accessed time to be set on the remote file.
498+
:mode The mode that the remote file should have.
499+
:mtime The last modified time to be set on the remote file.
500+
:size The size of the file in bytes."
501+
[session channel path props & body]
502+
`(let [session# ~session
503+
path# ~path
504+
props# ~props
505+
~channel (open-scp-send session# path# props#)]
388506
(try
389507
(do ~@body)
390508
(finally
391-
(close ~session ~channel)
392-
(free ~session ~channel)))))
509+
(close session# ~channel)
510+
(free session# ~channel)))))

src/clj_libssh2/ssh.clj

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
(ns clj-libssh2.ssh
2-
(:require [clj-libssh2.channel :as channel]
3-
[clj-libssh2.session :as session])
4-
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]
2+
(:require [clojure.java.io :refer [file]]
3+
[clj-libssh2.channel :as channel]
4+
[clj-libssh2.libssh2.scp :as libssh2-scp]
5+
[clj-libssh2.session :as session]
6+
[clj-libssh2.socket :as socket])
7+
(:import [java.io ByteArrayInputStream
8+
ByteArrayOutputStream
9+
FileInputStream
10+
FileOutputStream
11+
InputStream
12+
OutputStream]
513
[clj_libssh2.session Session]))
614

715
(defmacro with-session
@@ -93,3 +101,114 @@
93101
:err (if (contains? io :err) err (.toString err charset))
94102
:exit (channel/exit-status channel)
95103
:signal (channel/exit-signal session channel)})))))
104+
105+
(defn scp-from
106+
"Retrieve a file from a remote machine using SCP.
107+
108+
Arguments:
109+
110+
session-or-host Either a valid clj-libssh2.session.Session object or a map
111+
suitable for handing off to with-session.
112+
remote-path The path on the remote machine of the file you wish to
113+
retrieve.
114+
local-path The location on the local machine where the file should be
115+
copied to.
116+
117+
Return:
118+
119+
A map with the following keys and values:
120+
121+
:local-path The local-path argument.
122+
:remote-path The remote-path argument.
123+
:size The size of the file as read.
124+
:remote-stat A map with the following keys and values, corresponding to the
125+
fields in a struct stat as reported by the remote host:
126+
127+
:atime The last access time of the remote file.
128+
:ctime The last time the remote file's metadata changed.
129+
:gid The group ID of the remote file.
130+
:mode The permission mask for the remote file.
131+
:mtime The last modified time for the remote file.
132+
:size The size of the file on the remote system.
133+
:uid The user ID of the remote file.
134+
135+
Note 1: The destination file at local-path _may_ exist even after this
136+
function throws an exception. It's up to the caller to handle
137+
partial downloads.
138+
Note 2: Given a return map `m` (-> m :size) and (-> m :remote-stat :size)
139+
should be equal. If they are not equal then steps should be taken
140+
to verify the download."
141+
[session-or-host remote-path local-path]
142+
(with-session session session-or-host
143+
(channel/with-scp-recv-channel session channel remote-path fileinfo
144+
(let [output (FileOutputStream. local-path)
145+
file-size (.getSize fileinfo)
146+
read-chunk-size (-> session :options :read-chunk-size)
147+
read-timeout (-> session :options :read-timeout)
148+
finish (fn [bytes-read]
149+
(.close output)
150+
{:local-path local-path
151+
:remote-path remote-path
152+
:size bytes-read
153+
:remote-stat {:atime (.getATime fileinfo)
154+
:ctime (.getCTime fileinfo)
155+
:gid (.getGroupID fileinfo)
156+
:mode (.getMode fileinfo)
157+
:mtime (.getMTime fileinfo)
158+
:size file-size
159+
:uid (.getUserID fileinfo)}})]
160+
(loop [bytes-read 0
161+
last-read (System/currentTimeMillis)]
162+
(when (< read-timeout (- (System/currentTimeMillis) last-read))
163+
(throw (ex-info "Read timeout while receiving file"
164+
{:remote-path remote-path
165+
:local-path local-path
166+
:bytes-read bytes-read
167+
:timeout read-timeout
168+
:session session})))
169+
(if (< bytes-read file-size)
170+
(let [read-size (min (- file-size bytes-read) read-chunk-size)
171+
res (channel/read session channel 0 read-size)
172+
status (:status res)
173+
received (:received res)
174+
data (:data res)]
175+
(if (not= :eof status)
176+
(do
177+
(when (< 0 received)
178+
(.write output data 0 received))
179+
(when (= :eagain status)
180+
(socket/wait))
181+
(recur (+ bytes-read received) (System/currentTimeMillis)))
182+
(finish (+ bytes-read received))))
183+
(finish bytes-read)))))))
184+
185+
(defn scp-to
186+
"Send a file to a remote machine using SCP.
187+
188+
Arguments:
189+
190+
session-or-host Either a valid clj-libssh2.session.Session object or a map
191+
suitable for handing off to with-session.
192+
local-path The location on the local machine where the file should be
193+
copied from.
194+
remote-path The path on the remote machine where the file should be
195+
placed.
196+
197+
Optional keyword arguments:
198+
199+
:atime The last access time which the remote file should have.
200+
:mode The permissions mask which should be applied to the remote file.
201+
:mtime The last modified time which the remote file should have.
202+
203+
Returns:
204+
205+
nil"
206+
[session-or-host local-path remote-path & {:keys [atime mode mtime]
207+
:as props}]
208+
(with-session session session-or-host
209+
(let [local-file (file local-path)
210+
input (FileInputStream. local-file)
211+
props (merge {:size (.length local-file)
212+
:mtime (.lastModified local-file)} props)]
213+
(channel/with-scp-send-channel session channel remote-path props
214+
(channel/pump session channel {0 input} {})))))

test/clj_libssh2/test_ssh.clj

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns clj-libssh2.test-ssh
2-
(:require [clojure.string :as str]
2+
(:require [clojure.java.io :refer [file]]
3+
[clojure.string :as str]
34
[clojure.test :refer :all]
45
[clj-libssh2.ssh :as ssh]
56
[clj-libssh2.test-utils :as test]))
@@ -86,3 +87,53 @@
8687
(testing "Commands that take too long result in a timeout"
8788
(is (thrown? Exception (ssh/exec {:port 2222 :read-timeout 500}
8889
"echo foo; sleep 1; echo bar")))))
90+
91+
(deftest scp-from-can-copy-files
92+
(testing "scp-from can copy files from the remote host"
93+
(let [tmpdir (System/getProperty "java.io.tmpdir")
94+
remote-path (str tmpdir "/" (name (gensym "source")))
95+
local-path (str tmpdir "/" (name (gensym "destination")))
96+
file-content "Some dummy content\nfor the test file.\n"
97+
exists? (fn [path]
98+
(.exists (file path)))]
99+
(is (not (exists? remote-path)))
100+
(is (not (exists? local-path)))
101+
(try
102+
(spit remote-path file-content)
103+
(is (exists? remote-path))
104+
(ssh/scp-from {:port 2222} remote-path local-path)
105+
(is (exists? local-path))
106+
(is (= file-content (slurp local-path)))
107+
(finally
108+
(.delete (file remote-path))
109+
(.delete (file local-path)))))))
110+
111+
(deftest scp-from-throws-when-the-remote-file-doesn't-exist
112+
(is (thrown? Exception (ssh/scp-from {:port 2222}
113+
"/does/not/exist"
114+
"/dev/null"))))
115+
116+
(deftest scp-to-can-copy-files
117+
(testing "scp-to can copy files to the remote host"
118+
(let [tmpdir (System/getProperty "java.io.tmpdir")
119+
local-path (str tmpdir "/" (name (gensym "source")))
120+
remote-path (str tmpdir "/" (name (gensym "destination")))
121+
file-content "Some dummy content\nfor the test file.\n"
122+
exists? (fn [path]
123+
(.exists (file path)))]
124+
(is (not (exists? remote-path)))
125+
(is (not (exists? local-path)))
126+
(try
127+
(spit local-path file-content)
128+
(is (exists? local-path))
129+
(ssh/scp-to {:port 2222} local-path remote-path)
130+
(is (exists? remote-path))
131+
(is (= file-content (slurp remote-path)))
132+
(finally
133+
(.delete (file remote-path))
134+
(.delete (file local-path)))))))
135+
136+
(deftest scp-to-throws-when-the-local-file-doesn't-exist
137+
(is (thrown? Exception (ssh/scp-to {:port 2222}
138+
"/does/not/exist"
139+
"/dev/null"))))

0 commit comments

Comments
 (0)