├── .formatter.exs ├── .gitignore ├── .toolversions ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── sftp_ex.ex └── sftp_ex │ ├── conn.ex │ ├── erl │ └── sftp.ex │ ├── key_provider.ex │ ├── logger.ex │ ├── sftp │ ├── access.ex │ ├── management.ex │ ├── stream.ex │ └── transfer.ex │ ├── ssh.ex │ └── types.ex ├── mix.exs ├── mix.lock ├── sftp_ex.iml └── test ├── data └── test_file.txt ├── sftp_ex ├── conn_test.exs └── sftp │ ├── access_test.exs │ ├── management_test.exs │ ├── stream_test.exs │ └── transfer_test.exs ├── support ├── mock │ ├── sftp │ │ └── behaviour.ex │ └── ssh │ │ └── behaviour.ex └── mocks.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [], 3 | inputs: [ 4 | "{mix,.formatter}.exs", 5 | "config/*.exs", 6 | "lib/mix/**/*.{ex,exs}", 7 | "lib/**/*.{ex,exs}", 8 | "test/**/*.{ex,exs}" 9 | ], 10 | line_length: 100 11 | ] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | .idea* 20 | 21 | .elixir-ls 22 | 23 | .asdf -------------------------------------------------------------------------------- /.toolversions: -------------------------------------------------------------------------------- 1 | elixir 1.12 2 | erlang 24.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Michael Dorman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SftpEx 2 | 3 | An Elixir wrapper around the Erlang SFTP application. This allows for the use of Elixir Streams to 4 | transfer files via SFTP. 5 | 6 | ## Installation 7 | 8 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 9 | 10 | Add `sftp_ex` to your list of dependencies in `mix.exs` and `mix deps.get` from a terminal: 11 | 12 | ```elixir 13 | def deps do 14 | [{:sftp_ex, "~> 0.3.0"}] 15 | end 16 | ``` 17 | 18 | ## Configurable otions 19 | 20 | ``` 21 | config :sftp_ex, :host, "sftp.your.server.com" # no default 22 | config :sftp_ex, :user, "mr_anderson" # no default 23 | config :sftp_ex, :port, 2337 # defaults to 22 24 | config :sftp_ex, :cert, "fake" # no default and you should inject this value 25 | config :sftp_ex, :password, "worst_password_ever" # no default and you should inject this value 26 | config :sftp_ex, :key_cb, YourKeyProvider # only change this if you know what you're doing and you want to handle your key provider yourself 27 | config :sftp_ex, :timeout, 60_000 # defaults to 3_600_000 (an hour), you can set it to :infinity if you really want to 28 | ``` 29 | 30 | ## Creating a Connection 31 | 32 | The following is an example of creating a connection with a username and password. 33 | 34 | ```elixir 35 | {:ok, connection} = SftpEx.connect([host: "somehost", user: "someuser", password: "somepassword"]) 36 | ``` 37 | 38 | ### or 39 | 40 | You could put all of these values in config and load them at runtime and start with no args. 41 | 42 | ```elixir 43 | {:ok, connection} = SftpEx.connect() 44 | ``` 45 | 46 | **Do not** put sensitive information in configs such as passwords or certs. Instead use a file which is not committed to your repo or a secret holding service like Vault. Then you can inject these values at runtime using [`Application.put_env/2`](https://hexdocs.pm/elixir/1.12/Application.html) 47 | 48 | 49 | Other connection arguments can be found in the [Erlang documentation]("http://erlang.org/doc/man/ssh.html#connect-3") 50 | 51 | ## Concepts 52 | 53 | For some operations you'll need a file handle. To get one use `open/3` 54 | 55 | 56 | ```elixir 57 | SftpEx.open( conn, "remote_path") 58 | ``` 59 | 60 | Most things that one would think should have a timeout have a timeout. You can use the default one or your own timeout set in config and/or you can put it as an optional final arguent in most function calls. 61 | ## Streaming Files 62 | 63 | An example of writing a file to a server is the following. 64 | 65 | ```elixir 66 | stream = 67 | File.stream!("filename.txt") 68 | |> Stream.into(SftpEx.stream!(connection,"/home/path/filename.txt")) 69 | |> Stream.run 70 | ``` 71 | 72 | A file can be downloaded as follows - in this example a remote file "test2.csv" is downloaded to 73 | the local file "filename.txt" 74 | 75 | ```elixir 76 | SftpEx.stream!(connection,"test2.csv") |> Stream.into(File.stream!("filename.txt")) |> Stream.run 77 | ``` 78 | 79 | or using Enum.into 80 | 81 | ```elixir 82 | SftpEx.stream!(connection, "test2.csv") |> Enum.into(File.stream!("filename.txt")) 83 | ``` 84 | 85 | This follows the same pattern as Elixir IO streams so a file can be transferred 86 | from one server to another via SFTP as follows. 87 | 88 | ```elixir 89 | stream = 90 | SftpEx.stream!(connection,"/home/path/filename.txt") 91 | |> Stream.into(SftpEx.stream!(connection2,"/home/path/filename.txt")) 92 | |> Stream.run 93 | ``` 94 | 95 | ## Upload a file 96 | 97 | ```elixir 98 | SftpEx.upload(connection, "remote_path", data) 99 | ``` 100 | 101 | ## Download a file 102 | 103 | ```elixir 104 | SftpEx.download(connection, "remote_path/carrot_recipes.txt") 105 | ``` 106 | 107 | ## Download a directory 108 | 109 | ```elixir 110 | SftpEx.download(connection, "remote_path/cat_vids") 111 | ``` 112 | 113 | ## List files in a directory 114 | 115 | ```elixir 116 | SftpEx.ls(connection, "remote_path/cat_vids") 117 | ``` 118 | 119 | ## Make a directory 120 | 121 | ```elixir 122 | SftpEx.mkdir(connection, "remote_path/cat_vids") 123 | ``` 124 | 125 | ## Get file info 126 | 127 | ```elixir 128 | SftpEx.lstat(connection, "remote_path/cat_vids/cat_on_trampoline.mp4") 129 | 130 | ## or 131 | 132 | SftpEx.lstat(connection, handle) 133 | ``` 134 | 135 | ## Get file size 136 | 137 | ```elixir 138 | SftpEx.size(connection, "remote_path/cat_vids/cat_on_trampoline.mp4") 139 | 140 | ## or 141 | 142 | SftpEx.size(connection, handle) 143 | ``` 144 | 145 | ## Get file type eg `:regular` or `:directory` 146 | 147 | ```elixir 148 | SftpEx.get_type(connection, "remote_path/cat_vids/cat_on_trampoline.mp4") 149 | ``` 150 | 151 | ## When you're done, `disconnect` 152 | 153 | ```elixir 154 | SftpEx.disconnect(connection) 155 | ``` 156 | 157 | ## Remove a file from the server 158 | 159 | ```elixir 160 | SSftpEx.rm(connection, "remote_path/cat_vids/cat_on_trampoline.mp4") 161 | ``` 162 | 163 | ## Remove a directory from the server 164 | 165 | ```elixir 166 | SSftpEx.rm_dir(connection, "remote_path/cat_vids/cat_on_trampoline.mp4") 167 | ``` 168 | 169 | ## Rename a directory or file on the server 170 | 171 | ```elixir 172 | SSftpEx.rm_dir(connection, "remote_path/cat_vids/cat_on_trampoline.mp4", "remote_path/cat_vids/old_cat_on_trampoline.mp4") 173 | ``` 174 | 175 | ## Append an existing file 176 | 177 | ```elixir 178 | SSftpEx.append(connection, "remote_path/cat_vids/cat_on_trampoline.mp4", more_data) 179 | ``` 180 | 181 | ## That's not all! 182 | 183 | There is a lot of functionality exposed that isn't made available at the highest level that you can still utilize. Just dig into the code a bit and you'll see how. 184 | 185 | Also as this is just a wrapper for `:ssh_sftp` you can still use anything in that lib and it will play nice with this one. 186 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :sftp_ex, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:sftp_ex, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :sftp_ex, :ssh_service, SftpEx.Ssh 4 | config :sftp_ex, :sftp_service, SftpEx.Erl.Sftp 5 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :sftp_ex, :ssh_service, SftpEx.Ssh 4 | config :sftp_ex, :sftp_service, SftpEx.Erl.Sftp 5 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :sftp_ex, :ssh_service, Mock.SftpEx.Ssh 4 | config :sftp_ex, :sftp_service, Mock.SftpEx.Erl.Sftp 5 | 6 | config :sftp_ex, :host, "host" 7 | config :sftp_ex, :port, 22 8 | config :sftp_ex, :user, "user" 9 | 10 | config :sftp_ex, :cert, "fake" 11 | -------------------------------------------------------------------------------- /lib/sftp_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx do 2 | @moduledoc """ 3 | Functions for transferring and managing files through SFTP 4 | """ 5 | 6 | alias SFTP.Connection, as: Conn 7 | alias SftpEx.Sftp.Access 8 | alias SftpEx.Sftp.Management 9 | alias SftpEx.Sftp.Stream 10 | alias SftpEx.Sftp.Transfer 11 | alias SftpEx.Types, as: T 12 | 13 | @default_opts [ 14 | user_interaction: false, 15 | silently_accept_hosts: true, 16 | rekey_limit: 1_000_000_000_000, 17 | port: 22, 18 | # port: Application.get_env(:sftp_ex, :port, 22), 19 | # host: Application.get_env(:sftp_ex, :host) |> T.charlist(), 20 | # user: Application.get_env(:sftp_ex, :user) |> T.charlist(), 21 | # password: Application.get_env(:sftp_ex, :password) |> T.charlist(), 22 | key_cb: SftpEx.KeyProvider 23 | ] 24 | 25 | @valid_keys [:port, :host, :user, :password] 26 | 27 | @doc """ 28 | Creates a SFTP.Connection struct if the connection is successful, 29 | else will return {:error, reason} 30 | 31 | A connection struct will contain the 32 | channel_pid = pid() 33 | connection_pid = pid() 34 | host = charlist() | binary() 35 | port = charlist() | binary() 36 | opts = [{:option, :value}] 37 | 38 | Default values are set for the following options: 39 | 40 | user_interaction: false, 41 | silently_accept_hosts: true, 42 | rekey_limit: 1000000000000, 43 | port: 22 44 | 45 | ***NOTE: The only required option is ':host' 46 | 47 | The rekey_limit value is set at a large amount because the Erlang library creates 48 | an exception when the server is negotiating a rekey. Setting the value at a high number 49 | of bytes will avoid a rekey event occurring. 50 | 51 | Other available options can be found at http://erlang.org/doc/man/ssh.html#connect-3 52 | 53 | Returns {:ok, SFTP.Connection.t()} | {:error, reason} 54 | """ 55 | 56 | @spec connect(keyword) :: {:ok, Conn.t()} | T.error_tuple() 57 | 58 | def connect(opts \\ []) do 59 | opts = (@default_opts ++ config_opts()) |> Keyword.merge(opts) 60 | own_keys = [:host, :port] 61 | ssh_opts = opts |> Enum.filter(fn {k, _} -> k not in own_keys end) 62 | Conn.connect(opts[:host], opts[:port], ssh_opts) 63 | end 64 | 65 | defp config_opts do 66 | Application.get_all_env(:sftp_ex) 67 | |> Enum.filter(fn 68 | {key, _v} when key in @valid_keys -> true 69 | _ -> false 70 | end) 71 | |> Enum.map(fn 72 | {key, value} when is_binary(value) -> {key, T.charlist(value)} 73 | kv -> kv 74 | end) 75 | end 76 | 77 | @doc """ 78 | Download a file or directory given the connection and remote_path 79 | 80 | Types: 81 | connection = SFTP.Connection.t() 82 | remote_path = String.t() or charlist() 83 | 84 | Optional: timeout = integer() 85 | 86 | Returns [T.data()] or [[T.data()]] or {:error, reason} 87 | """ 88 | 89 | @spec download(Conn.t(), T.either_string(), timeout()) :: 90 | [T.data()] | [[T.data()]] | T.error_tuple() 91 | 92 | def download(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 93 | Transfer.download(conn, remote_path, timeout) 94 | end 95 | 96 | @doc """ 97 | Uploads data to a remote path via SFTP 98 | 99 | Types: 100 | connection = SFTP.Connection.t() 101 | remote_path = String.t() or charlist() 102 | file_handle = T.handle() 103 | 104 | Optional: timeout = integer() 105 | 106 | Returns :ok or {:error, reason} 107 | """ 108 | 109 | @spec upload(Conn.t(), T.either_string(), T.handle(), timeout()) :: :ok | T.error_tuple() 110 | 111 | def upload(%Conn{} = conn, remote_path, file_handle, timeout \\ Conn.timeout()) do 112 | Transfer.upload(conn, remote_path, file_handle, timeout) 113 | end 114 | 115 | @doc """ 116 | Creates an SFTP stream by opening an SFTP connection and opening a file 117 | in read or write mode. 118 | 119 | Below is an example of reading a file from a server. 120 | 121 | An example of writing a file to a server is the following. 122 | 123 | stream = File.stream!("filename.txt") 124 | |> Stream.into(SftpEx.stream!(connection,"/home/path/filename.txt")) 125 | |> Stream.run 126 | 127 | This follows the same pattern as Elixir IO streams so a file can be transferred 128 | from one server to another via SFTP as follows. 129 | 130 | stream = SftpEx.stream!(connection,"/home/path/filename.txt") 131 | |> Stream.into(SftpEx.stream!(connection2,"/home/path/filename.txt")) 132 | |> Stream.run 133 | 134 | Types: 135 | connection = SFTP.Connection.t() 136 | remote_path = String.t() or charlist() 137 | 138 | Optional: timeout = integer() 139 | 140 | Returns SftpEx.Sftp.Stream.t() 141 | """ 142 | 143 | @spec stream!(Conn.t(), T.either_string(), non_neg_integer()) :: Stream.t() 144 | 145 | def stream!(%Conn{} = conn, remote_path, byte_size \\ 32768) do 146 | Stream.new(conn, remote_path, byte_size) 147 | end 148 | 149 | @doc """ 150 | Opens a file or directory given a connection and remote_path 151 | 152 | Types: 153 | connection = SFTP.Connection.t() 154 | remote_path = String.t() or charlist() 155 | 156 | Optional: timeout = integer() 157 | 158 | Returns {:ok, T.handle()} or {:error, reason} 159 | """ 160 | 161 | @spec open(Conn.t(), T.either_string(), timeout()) :: {:ok, T.handle()} | T.error_tuple() 162 | 163 | def open(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 164 | Access.open(conn, remote_path, [:read, :binary], timeout) 165 | end 166 | 167 | @doc """ 168 | Lists the contents of a directory given a connection a handle or remote path 169 | 170 | Types: 171 | connection = SFTP.Connection.t() 172 | remote_path = String.t() or charlist() 173 | 174 | Optional: timeout = integer() 175 | 176 | Returns {:ok, [Filename]}, or {:error, reason} 177 | """ 178 | 179 | @spec ls(Conn.t(), T.either_string(), timeout()) :: {:ok, list()} | T.error_tuple() 180 | 181 | def ls(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 182 | Management.list_files(conn, remote_path, timeout) 183 | end 184 | 185 | @doc """ 186 | Lists the contents of a directory given a connection a handle or remote path 187 | Types: 188 | connection = SFTP.Connection.t() 189 | remote_path = String.t() or charlist() 190 | 191 | Optional: timeout = integer() 192 | 193 | Returns :ok or {:error, reason} 194 | """ 195 | 196 | @spec mkdir(Conn.t(), T.either_string(), timeout()) :: {:ok, list()} | T.error_tuple() 197 | 198 | def mkdir(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 199 | Management.make_directory(conn, remote_path, timeout) 200 | end 201 | 202 | @doc """ 203 | Types: 204 | connection = SFTP.Connection.t() 205 | remote_path = String.t() or charlist() or T.handle() 206 | 207 | Optional: timeout = integer() 208 | 209 | Returns {:ok, File.Stat.t()}, or {:error, reason} 210 | """ 211 | 212 | @spec lstat(Conn.t(), T.either_string() | T.handle(), timeout()) :: 213 | {:ok, File.Stat.t()} | T.error_tuple() 214 | 215 | def lstat(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 216 | Access.file_info(conn, remote_path, timeout) 217 | end 218 | 219 | @doc """ 220 | Size of the file in bytes 221 | Types: 222 | connection = SFTP.Connection.t() 223 | remote_path = String.t() or charlist() or T.handle() 224 | 225 | Optional: timeout = integer() 226 | 227 | Returns size as {:ok, integer()} or {:error, reason} 228 | """ 229 | 230 | @spec size(Conn.t(), T.either_string(), timeout()) :: {:ok, integer()} | T.error_tuple() 231 | 232 | def size(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 233 | case Access.file_info(conn, remote_path, timeout) do 234 | {:error, reason} -> {:error, reason} 235 | {:ok, info} -> info.size 236 | end 237 | end 238 | 239 | @doc """ 240 | Gets the type given a remote path. 241 | 242 | Types: 243 | connection = SFTP.Connection.t() 244 | remote_path = String.t() or charlist() or T.handle() 245 | 246 | Optional: timeout = integer() 247 | 248 | type = :device | :directory | :regular | :other | :symlink 249 | 250 | Returns {:ok, type}, or {:error, reason} 251 | """ 252 | 253 | @spec get_type(Conn.t(), T.either_string(), timeout()) :: {:ok, T.file_type({})} | T.error_tuple() 254 | 255 | def get_type(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 256 | case Access.file_info(conn, remote_path, timeout) do 257 | {:error, reason} -> {:error, reason} 258 | {:ok, info} -> info.type 259 | end 260 | end 261 | 262 | @doc """ 263 | Stops the SSH application 264 | 265 | Types: 266 | connection = SFTP.Connection.t() 267 | 268 | Returns :ok 269 | """ 270 | 271 | @spec disconnect(Conn.t()) :: :ok 272 | 273 | def disconnect(%Conn{} = conn) do 274 | Conn.disconnect(conn) 275 | end 276 | 277 | @doc """ 278 | Removes a file from the server. 279 | Types: 280 | connection = SFTP.Connection.t() 281 | file = String.t() or charlist() 282 | 283 | Optional: timeout = integer() 284 | 285 | Returns :ok, or {:error, reason} 286 | """ 287 | 288 | @spec rm(Conn.t(), T.either_string(), timeout()) :: :ok | T.error_tuple() 289 | 290 | def rm(%Conn{} = conn, file, timeout \\ Conn.timeout()) do 291 | Management.remove_file(conn, file, timeout) 292 | end 293 | 294 | @doc """ 295 | Removes a directory and all files within it 296 | Types: 297 | connection = SFTP.Connection.t() 298 | remote_path = String.t() or charlist() 299 | 300 | Optional: timeout = integer() 301 | 302 | Returns :ok or {:error, reason} 303 | """ 304 | 305 | @spec rm_dir(Conn.t(), T.either_string(), timeout()) :: :ok | T.error_tuple() 306 | 307 | def rm_dir(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 308 | Management.remove_directory(conn, remote_path, timeout) 309 | end 310 | 311 | @doc """ 312 | Renames a file or directory 313 | 314 | Types: 315 | connection = SFTP.Connection.t() 316 | old_name = String.t() or charlist() 317 | new_name = String.t() or charlist() 318 | 319 | Optional: timeout = integer() 320 | 321 | Returns {:ok, T.handle()} or {:error, reason} 322 | """ 323 | 324 | @spec rename(Conn.t(), T.either_string(), T.either_string(), timeout()) :: 325 | {:ok, T.handle()} | T.error_tuple() 326 | 327 | def rename(%Conn{} = conn, old_name, new_name, timeout \\ Conn.timeout()) do 328 | Management.rename(conn, old_name, new_name, timeout) 329 | end 330 | 331 | @doc """ 332 | Appends a file with more data 333 | 334 | Types: 335 | connection = SFTP.Connection.t() 336 | remote_path = String.t() or charlist() 337 | data = T.data() 338 | 339 | Optional: timeout = integer() 340 | 341 | Returns :ok or {:error, reason} 342 | """ 343 | 344 | @spec append(Conn.t(), T.either_string(), T.data(), timeout()) :: 345 | {:ok, T.handle()} | T.error_tuple() 346 | 347 | def append(%Conn{} = conn, remote_path, data, timeout \\ Conn.timeout()) do 348 | Management.append_file(conn, remote_path, data, timeout) 349 | end 350 | end 351 | -------------------------------------------------------------------------------- /lib/sftp_ex/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule SFTP.Connection do 2 | @moduledoc """ 3 | Provides methods related to starting and stopping an SFTP connection 4 | 5 | Atypical naming to maintain backwards compatibility 6 | """ 7 | 8 | require SftpEx.Logger, as: Logger 9 | 10 | alias SftpEx.Types, as: T 11 | 12 | @ssh Application.get_env(:sftp_ex, :ssh_service, SftpEx.Ssh) 13 | @sftp Application.get_env(:sftp_ex, :sftp_service, SftpEx.Erl.Sftp) 14 | 15 | @host Application.get_env(:sftp_ex, :host) 16 | @port Application.get_env(:sftp_ex, :port, 22) 17 | @opts [ 18 | user: Application.get_env(:sftp_ex, :user), 19 | key_cb: SftpEx.KeyProvider 20 | ] 21 | 22 | defstruct channel_pid: nil, connection_ref: nil, host: nil, port: 22, opts: [] 23 | 24 | # Default in :ssh_sftp is :infinity... seems like an hour is more reasonable 25 | # Set it in config if you want something else 26 | def timeout, do: Application.get_env(:sftp_ex, :timeout, 3_600_000) 27 | 28 | @type t :: %__MODULE__{ 29 | channel_pid: T.channel_pid(), 30 | connection_ref: T.connection_ref(), 31 | host: T.host(), 32 | port: T.port(), 33 | opts: list() 34 | } 35 | 36 | @doc """ 37 | Creates a new Conn with given values 38 | """ 39 | 40 | @spec new(pid, :ssh.connection_ref(), T.host(), T.port_number(), list) :: C.t() 41 | 42 | def new(channel_pid, connection_ref, host \\ @host, port \\ @port, opts \\ @opts) do 43 | %__MODULE__{ 44 | channel_pid: channel_pid, 45 | connection_ref: connection_ref, 46 | host: host, 47 | port: port, 48 | opts: opts 49 | } 50 | end 51 | 52 | @doc """ 53 | Stops a SFTP channel and closes the SSH connection. 54 | 55 | Returns :ok 56 | """ 57 | 58 | @spec disconnect(Connection.t()) :: :ok | T.error_tuple() 59 | 60 | def disconnect(conn) do 61 | @sftp.stop_channel(conn) 62 | @ssh.close_connection(conn) 63 | end 64 | 65 | @doc """ 66 | Creates an SFTP connection 67 | Returns {:ok, Connection}, or {:error, reason} 68 | """ 69 | 70 | @spec connect(T.host(), T.port(), list) :: {:ok, Connection.t()} | T.error_tuple() 71 | 72 | def connect(host, port, opts) do 73 | @ssh.start() 74 | 75 | case @sftp.start_channel(host, port, opts) do 76 | {:ok, channel_pid, connection_ref} -> 77 | {:ok, new(channel_pid, connection_ref, host, port, opts)} 78 | 79 | e -> 80 | Logger.handle_error(e, [__MODULE__, :connect]) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/sftp_ex/erl/sftp.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Erl.Sftp do 2 | @moduledoc " 3 | A wrapper around the Erlang SFTP library 4 | " 5 | 6 | alias SFTP.Connection, as: Conn 7 | alias SftpEx.Types, as: T 8 | 9 | @spec start_channel(T.host(), T.port_number(), list) :: 10 | {:ok, T.channel_pid(), :ssh.connection_ref()} | T.error_tuple() 11 | 12 | def start_channel(host, port, opts) do 13 | :ssh_sftp.start_channel(host, port, opts) 14 | end 15 | 16 | @spec stop_channel(Conn.t()) :: :ok 17 | 18 | def stop_channel(%Conn{} = conn) do 19 | :ssh_sftp.stop_channel(conn.channel_pid) 20 | end 21 | 22 | @spec rename(Conn.t(), charlist, charlist, timeout) :: :ok | T.error_tuple() 23 | 24 | def rename(%Conn{} = conn, old_name, new_name, timeout \\ Conn.timeout()) do 25 | :ssh_sftp.rename(conn.channel_pid, old_name, new_name, timeout) 26 | end 27 | 28 | @spec read_file_info(Conn.t(), charlist, timeout) :: {:ok, T.file_info()} | T.error_tuple() 29 | 30 | def read_file_info(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 31 | :ssh_sftp.read_file_info(conn.channel_pid, remote_path, timeout) 32 | end 33 | 34 | @spec list_dir(Conn.t(), charlist, timeout) :: {:ok, [charlist]} | T.error_tuple() 35 | 36 | def list_dir(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 37 | :ssh_sftp.list_dir(conn.channel_pid, remote_path, timeout) 38 | end 39 | 40 | @spec delete(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 41 | 42 | def delete(%Conn{} = conn, file, timeout \\ Conn.timeout()) do 43 | :ssh_sftp.delete(conn.channel_pid, file, timeout) 44 | end 45 | 46 | @spec delete_directory(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 47 | 48 | def delete_directory(%Conn{} = conn, directory_path, timeout \\ Conn.timeout()) do 49 | :ssh_sftp.del_dir(conn.channel_pid, directory_path, timeout) 50 | end 51 | 52 | @spec make_directory(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 53 | 54 | def make_directory(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 55 | :ssh_sftp.make_dir(conn.channel_pid, remote_path, timeout) 56 | end 57 | 58 | @spec close(Conn.t(), T.handle(), timeout) :: :ok | T.error_tuple() 59 | 60 | def close(%Conn{} = conn, handle, timeout \\ Conn.timeout()) do 61 | :ssh_sftp.close(conn.channel_pid, handle, timeout) 62 | end 63 | 64 | @spec open(Conn.t(), list, T.mode(), timeout) :: {:ok, T.handle()} | T.error_tuple() 65 | 66 | def open(%Conn{} = conn, remote_path, mode, timeout \\ Conn.timeout()) do 67 | :ssh_sftp.open(conn.channel_pid, remote_path, mode, timeout) 68 | end 69 | 70 | @spec open_directory(Conn.t(), list, timeout) :: {:ok, T.handle()} | T.error_tuple() 71 | 72 | def open_directory(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 73 | :ssh_sftp.opendir(conn.channel_pid, remote_path, timeout) 74 | end 75 | 76 | @spec read(Conn.t(), T.handle(), non_neg_integer, timeout) :: 77 | {:ok, T.data()} | :eof | T.error_tuple() 78 | 79 | def read(%Conn{} = conn, handle, byte_length, timeout \\ Conn.timeout()) do 80 | :ssh_sftp.read(conn.channel_pid, handle, byte_length, timeout) 81 | end 82 | 83 | @spec write(Conn.t(), T.handle(), iodata, timeout) :: :ok | T.error_tuple() 84 | 85 | def write(%Conn{} = conn, handle, data, timeout \\ Conn.timeout()) do 86 | :ssh_sftp.write(conn.channel_pid, handle, data, timeout) 87 | end 88 | 89 | @spec write_file(Conn.t(), charlist, iodata, timeout) :: :ok | T.error_tuple() 90 | 91 | def write_file(%Conn{} = conn, remote_path, data, timeout \\ Conn.timeout()) do 92 | :ssh_sftp.write_file(conn.channel_pid, remote_path, data, timeout) 93 | end 94 | 95 | @spec read_file(Conn.t(), charlist, timeout) :: {:ok, T.data()} | T.error_tuple() 96 | 97 | def read_file(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 98 | :ssh_sftp.read_file(conn.channel_pid, remote_path, timeout) 99 | end 100 | 101 | @spec position(Conn.t(), T.handle(), T.location(), timeout) :: 102 | {:ok, non_neg_integer()} | T.error_tuple() 103 | 104 | def position(%Conn{} = conn, handle, location, timeout \\ Conn.timeout()) do 105 | :ssh_sftp.position(conn.channel_pid, handle, location, timeout) 106 | end 107 | 108 | @spec pread(Conn.t(), T.handle(), non_neg_integer(), non_neg_integer(), timeout) :: 109 | {:ok, T.data()} | :eof | T.error_tuple() 110 | 111 | def pread(%Conn{} = conn, handle, position, length, timeout \\ Conn.timeout()) do 112 | :ssh_sftp.pread(conn.channel_pid, handle, position, length, timeout) 113 | end 114 | 115 | @spec pwrite(Conn.t(), T.handle(), non_neg_integer(), T.data(), timeout) :: :ok | T.error_tuple() 116 | 117 | def pwrite(%Conn{} = conn, handle, position, data, timeout \\ Conn.timeout()) do 118 | :ssh_sftp.pwrite(conn.channel_pid, handle, position, data, timeout) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/sftp_ex/key_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.KeyProvider do 2 | @moduledoc """ 3 | Module for making configured private key available for SFTP 4 | Original source for reference https://gist.github.com/jrissler/1cfa9fab8b55a1004bc74e3bffeb9739 5 | """ 6 | 7 | @behaviour :ssh_client_key_api 8 | 9 | @impl :ssh_client_key_api 10 | defdelegate add_host_key(host, port, public_key, opts), to: :ssh_file 11 | 12 | @impl :ssh_client_key_api 13 | defdelegate is_host_key(key, host, port, algorithm, opts), to: :ssh_file 14 | 15 | @impl :ssh_client_key_api 16 | def user_key(_algorithm, _opts) do 17 | decoded_pem = 18 | priv_key() 19 | |> :public_key.pem_decode() 20 | |> List.first() 21 | 22 | {:ok, :public_key.pem_entry_decode(decoded_pem)} 23 | end 24 | 25 | def priv_key do 26 | Application.get_env(:sftp_ex, :cert) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/sftp_ex/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Logger do 2 | require Logger 3 | 4 | @moduledoc false 5 | 6 | def handle_error(error, meta \\ []) do 7 | Logger.error("#{inspect(error: error, meta: meta)}") 8 | error 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/sftp_ex/sftp/access.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.Access do 2 | @moduledoc """ 3 | Functions for accessing files and directories 4 | """ 5 | 6 | require SftpEx.Logger, as: Logger 7 | 8 | alias SFTP.Connection, as: Conn 9 | alias SftpEx.Types, as: T 10 | 11 | @sftp Application.get_env(:sftp_ex, :sftp_service, SftpEx.Erl.Sftp) 12 | 13 | @doc """ 14 | Closes an open file 15 | Returns :ok, or {:error, reason} 16 | """ 17 | 18 | @spec close(Conn.t(), T.handle(), timeout) :: 19 | :ok | {:error, atom()} 20 | 21 | def close(%Conn{} = conn, handle, timeout \\ Conn.timeout()) do 22 | case @sftp.close(conn, handle, timeout) do 23 | :ok -> :ok 24 | e -> Logger.handle_error(e) 25 | end 26 | end 27 | 28 | @doc """ 29 | Returns {:ok, File.Stat}, or {:error, reason} 30 | """ 31 | 32 | @spec file_info(Conn.t(), T.either_string() | T.handle(), timeout) :: 33 | {:ok, File.Stat.t()} | {:error, atom()} 34 | 35 | def file_info(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 36 | case @sftp.read_file_info(conn, T.charlist_or_handle(remote_path), timeout) do 37 | {:ok, file_info} -> {:ok, File.Stat.from_record(file_info)} 38 | e -> Logger.handle_error(e) 39 | end 40 | end 41 | 42 | @doc """ 43 | Opens a file or directory given a channel PID and path. 44 | {:ok, handle}, or {:error, reason} 45 | """ 46 | 47 | @spec open(Conn.t(), T.either_string(), T.mode(), timeout) :: 48 | {:ok, File.Stat.t()} | {:error, atom()} 49 | 50 | def open(%Conn{} = conn, path, mode, timeout \\ Conn.timeout()) do 51 | case file_info(conn, T.charlist(path), timeout) do 52 | {:ok, info} -> 53 | case info.type do 54 | :directory -> open_dir(conn, path, timeout) 55 | _ -> open_file(conn, path, mode, timeout) 56 | end 57 | 58 | e -> 59 | Logger.handle_error(e) 60 | end 61 | end 62 | 63 | @doc """ 64 | Opens a file given a channel PID and path. 65 | {:ok, handle}, or {:error, reason} 66 | """ 67 | 68 | @spec open_file(Conn.t(), T.either_string(), T.mode(), timeout) :: 69 | {:ok, T.handle()} | T.error_tuple() 70 | 71 | def open_file(%Conn{} = conn, remote_path, mode, timeout \\ Conn.timeout()) do 72 | @sftp.open(conn, T.charlist(remote_path), mode, timeout) 73 | end 74 | 75 | @doc """ 76 | Opens a directory given a channel PID and path. 77 | {:ok, handle}, or {:error, reason} 78 | """ 79 | 80 | @spec open_dir(Conn.t(), T.either_string(), timeout) :: 81 | {:ok, T.handle()} | T.error_tuple() 82 | 83 | def open_dir(conn, remote_path, timeout \\ Conn.timeout()) do 84 | case @sftp.open_directory(conn, T.charlist(remote_path), timeout) do 85 | {:ok, handle} -> {:ok, handle} 86 | e -> Logger.handle_error(e) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/sftp_ex/sftp/management.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.Management do 2 | @moduledoc """ 3 | Provides methods for managing files through an SFTP connection 4 | """ 5 | 6 | require SftpEx.Logger, as: Logger 7 | require Logger 8 | 9 | @sftp Application.get_env(:sftp_ex, :sftp_service, SftpEx.Erl.Sftp) 10 | 11 | alias SFTP.Connection, as: Conn 12 | alias SftpEx.Sftp.Access 13 | alias SftpEx.Types, as: T 14 | 15 | @spec make_directory(Conn.t(), T.either_string(), timeout()) :: :ok | T.error_tuple() 16 | 17 | def make_directory(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 18 | case @sftp.make_directory(conn, T.charlist(remote_path), timeout) do 19 | :ok -> :ok 20 | e -> Logger.handle_error(e) 21 | end 22 | end 23 | 24 | @doc """ 25 | Removes a directory and all files within the directory 26 | 27 | #Deletes a directory specified by Name. The directory must be empty before it can be successfully deleted. 28 | 29 | Types: 30 | conn = Conn.t() 31 | directory = string() 32 | 33 | Returns :ok, or {:error, reason} 34 | """ 35 | 36 | @spec remove_directory(Conn.t(), T.either_string(), timeout) :: :ok | T.error_tuple() 37 | 38 | def remove_directory(%Conn{} = conn, directory, timeout \\ Conn.timeout()) do 39 | case remove_all_files(conn, directory) do 40 | :ok -> 41 | case @sftp.delete_directory(conn, T.charlist(directory), timeout) do 42 | :ok -> :ok 43 | {:error, reason} -> {:error, reason} 44 | end 45 | 46 | {:error, reason} -> 47 | {:error, reason} 48 | end 49 | end 50 | 51 | @doc """ 52 | Removes a file 53 | Types: 54 | conn = Conn.t() 55 | file = string() 56 | 57 | Returns :ok, or {:error, reason} 58 | """ 59 | 60 | @spec remove_file(Conn.t(), T.either_string(), timeout()) :: :ok | T.error_tuple() 61 | 62 | def remove_file(%Conn{} = conn, file, timeout \\ Conn.timeout()) do 63 | case @sftp.delete(conn, T.charlist(file), timeout) do 64 | :ok -> :ok 65 | {:error, reason} -> {:error, reason} 66 | end 67 | end 68 | 69 | @doc """ 70 | Lists files in a directory 71 | """ 72 | 73 | @spec list_files(Conn.t(), T.either_string(), timeout()) :: 74 | {:ok, list} | T.error_tuple() 75 | 76 | def list_files(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 77 | with {:ok, %File.Stat{type: :directory}} <- Access.file_info(conn, remote_path, timeout), 78 | {:ok, file_list} <- @sftp.list_dir(conn, T.charlist(remote_path), timeout) do 79 | {:ok, Enum.reject(file_list, &dotted?/1)} 80 | else 81 | {:ok, %File.Stat{}} -> 82 | {:error, "Remote path is not a directory"} 83 | 84 | e -> 85 | e 86 | end 87 | end 88 | 89 | @doc """ 90 | Renames a file or directory, 91 | Returns {:ok, handle}, or {:error, reason} 92 | """ 93 | 94 | @spec rename(Conn.t(), T.either_string(), T.either_string(), timeout()) :: 95 | :ok | T.error_tuple() 96 | 97 | def rename(%Conn{} = conn, old_name, new_name, timeout \\ Conn.timeout()) do 98 | @sftp.rename(conn, T.charlist(old_name), T.charlist(new_name), timeout) 99 | end 100 | 101 | @doc """ 102 | Append to an existing file 103 | Returns :ok or {:error, reason} 104 | """ 105 | 106 | @spec append_file(Conn.t(), T.either_string(), T.data(), timeout()) :: :ok | T.error_tuple() 107 | 108 | def append_file(%Conn{} = conn, remote_path, data, timeout \\ Conn.timeout()) do 109 | # Get the size to know the starting point to append to 110 | with {:ok, %File.Stat{size: position, type: :regular}} <- 111 | Access.file_info(conn, remote_path, timeout), 112 | # Need to get a handle 113 | {:ok, handle} <- Access.open_file(conn, remote_path, [:append], timeout), 114 | # Write to the position at the end of the file aka size 115 | :ok <- @sftp.pwrite(conn, handle, position, data, timeout), 116 | # Must stop channel for changes to take effect 117 | :ok <- @sftp.stop_channel(conn) do 118 | :ok 119 | end 120 | end 121 | 122 | defp remove_all_files(%Conn{} = conn, directory, timeout \\ Conn.timeout()) do 123 | case list_files(conn, T.charlist(directory), timeout) do 124 | {:ok, filenames} -> 125 | Enum.each(filenames, &remove_file(conn, "#{directory}/#{&1}")) 126 | 127 | {:error, reason} -> 128 | {:error, reason} 129 | end 130 | end 131 | 132 | @spec dotted?(charlist()) :: boolean() 133 | defp dotted?('.'), do: true 134 | defp dotted?('..'), do: true 135 | defp dotted?(_), do: false 136 | end 137 | -------------------------------------------------------------------------------- /lib/sftp_ex/sftp/stream.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.Stream do 2 | @moduledoc " 3 | A stream to download/upload a file from a server through SFTP 4 | " 5 | 6 | alias SFTP.Connection, as: Conn 7 | alias SftpEx.Sftp.Access 8 | alias SftpEx.Sftp.Stream 9 | alias SftpEx.Sftp.Transfer 10 | alias SftpEx.Types, as: T 11 | 12 | defstruct conn: nil, path: nil, byte_length: 32768 13 | 14 | @type t :: %__MODULE__{ 15 | conn: Conn.t(), 16 | path: T.either_string(), 17 | byte_length: non_neg_integer 18 | } 19 | 20 | @spec new(Conn.t(), T.either_string(), non_neg_integer()) :: t 21 | def new(%Conn{} = conn, path, byte_length) do 22 | %__MODULE__{conn: conn, path: T.charlist(path), byte_length: byte_length} 23 | end 24 | 25 | defimpl Collectable do 26 | @spec into(Stream.t()) :: none 27 | def into(%Stream{conn: conn, path: path, byte_length: _byte_length} = stream) do 28 | case Access.open_file(conn, path, [:write, :binary, :creat]) do 29 | {:ok, handle} -> {:ok, into(conn, handle, stream)} 30 | {:error, reason} -> {:error, reason} 31 | end 32 | end 33 | 34 | defp into(conn, handle, stream) do 35 | fn 36 | :ok, {:cont, x} -> 37 | Transfer.write(conn, handle, x) 38 | 39 | :ok, :done -> 40 | :ok = Access.close(conn, handle) 41 | stream 42 | 43 | :ok, :halt -> 44 | :ok = Access.close(conn, handle) 45 | end 46 | end 47 | end 48 | 49 | defimpl Enumerable do 50 | @spec reduce(Stream.t(), {:cont, any} | {:halt, any} | {:suspend, any}, fun()) :: 51 | :badarg | {:halted, any} | {:suspended, any, (any -> any)} 52 | def reduce(%Stream{conn: conn, path: path, byte_length: byte_length}, acc, fun) do 53 | start_function = fn -> 54 | case Access.open(conn, path, [:read, :binary]) do 55 | {:ok, handle} -> handle 56 | {:error, reason} -> raise File.Error, reason: reason, action: "stream", path: path 57 | end 58 | end 59 | 60 | next_function = &Transfer.each_binstream(conn, &1, byte_length) 61 | 62 | close_function = &Access.close(conn, &1) 63 | 64 | Stream.resource(start_function, next_function, close_function).(acc, fun) 65 | end 66 | 67 | def count(_stream) do 68 | {:error, Stream} 69 | end 70 | 71 | def member?(_stream, _term) do 72 | {:error, Stream} 73 | end 74 | 75 | def slice(_stream) do 76 | {:error, Stream} 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/sftp_ex/sftp/transfer.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.Transfer do 2 | @moduledoc """ 3 | Provides data transfer related functions 4 | """ 5 | 6 | require SftpEx.Logger, as: Logger 7 | 8 | alias SFTP.Connection, as: Conn 9 | alias SftpEx.Types, as: T 10 | 11 | @sftp Application.get_env(:sftp_ex, :sftp_service, SftpEx.Erl.Sftp) 12 | 13 | @doc """ 14 | Similar to IO.each_binstream this returns a tuple with the data 15 | and the file handle if data is read from the server. If it reaches 16 | the end of the file then {:halt, handle} is returned where handle is 17 | the file handle 18 | """ 19 | 20 | @spec each_binstream(Conn.t(), T.handle(), non_neg_integer(), timeout()) :: 21 | {:halt, T.handle()} | {[T.data()], T.handle()} 22 | 23 | def each_binstream(%Conn{} = conn, handle, byte_length, timeout \\ Conn.timeout()) do 24 | case @sftp.read(conn, handle, byte_length, timeout) do 25 | :eof -> 26 | {:halt, handle} 27 | 28 | {:error, reason} -> 29 | raise IO.StreamError, reason: reason 30 | 31 | {:ok, data} -> 32 | {[data], handle} 33 | end 34 | end 35 | 36 | @doc """ 37 | Writes data to a open file using the channel PID 38 | """ 39 | 40 | @spec write(Conn.t(), T.handle(), iodata, timeout) :: :ok | T.error_tuple() 41 | 42 | def write(%Conn{} = conn, handle, data, timeout \\ Conn.timeout()) do 43 | case @sftp.write(conn, handle, data, timeout) do 44 | :ok -> :ok 45 | e -> Logger.handle_error(e) 46 | end 47 | end 48 | 49 | @doc """ 50 | Writes a file to a remote path given a file, remote path, and connection. 51 | """ 52 | 53 | @spec upload(Conn.t(), T.either_string(), T.data(), timeout()) :: :ok | T.error_tuple() 54 | 55 | def upload(%Conn{} = conn, remote_path, data, timeout \\ Conn.timeout()) do 56 | case @sftp.write_file(conn, T.charlist(remote_path), data, timeout) do 57 | :ok -> :ok 58 | e -> Logger.handle_error(e) 59 | end 60 | end 61 | 62 | @doc """ 63 | Downloads a remote path 64 | {:ok, data} if successful, {:error, reason} if unsuccessful 65 | """ 66 | 67 | @spec download(Conn.t(), T.either_string(), timeout) :: 68 | [[T.data()]] | [T.data()] | T.error_tuple() 69 | 70 | def download(%Conn{} = conn, remote_path, timeout \\ Conn.timeout()) do 71 | remote_path = T.charlist(remote_path) 72 | 73 | case @sftp.read_file_info(conn, remote_path, timeout) do 74 | {:ok, file_stat} -> 75 | case File.Stat.from_record(file_stat).type do 76 | :directory -> download_directory(conn, remote_path, timeout) 77 | :regular -> download_file(conn, remote_path, timeout) 78 | not_dir_or_file -> {:error, "Unsupported type: #{inspect(not_dir_or_file)}"} 79 | end 80 | 81 | e -> 82 | Logger.handle_error(e) 83 | end 84 | end 85 | 86 | defp download_file(%Conn{} = conn, remote_path, timeout) do 87 | case @sftp.read_file(conn, remote_path, timeout) do 88 | {:ok, data} -> [data] 89 | e -> Logger.handle_error(e) 90 | end 91 | end 92 | 93 | defp download_directory(%Conn{} = conn, remote_path, timeout) do 94 | case @sftp.list_dir(conn, remote_path, timeout) do 95 | {:ok, filenames} -> Enum.map(filenames, &download_file(conn, &1, timeout)) 96 | e -> Logger.handle_error(e) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/sftp_ex/ssh.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Ssh do 2 | @moduledoc """ 3 | A wrapper around the Erlang :ssh library 4 | """ 5 | 6 | require SftpEx.Logger, as: Logger 7 | 8 | alias SFTP.Connection, as: Conn 9 | alias SftpEx.Types, as: T 10 | 11 | @spec start :: :ok | T.error_tuple() 12 | def start do 13 | case :ssh.start() do 14 | :ok -> IO.puts("Connected") 15 | e -> Logger.handle_error(e) 16 | end 17 | end 18 | 19 | @doc """ 20 | Closes a SSH connection 21 | Returns :ok 22 | """ 23 | @spec close_connection(Conn.t()) :: :ok | T.error_tuple() 24 | def close_connection(conn) do 25 | :ssh.close(conn.connection_ref) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/sftp_ex/types.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Types do 2 | @moduledoc """ 3 | All the types and things to deal with types 4 | """ 5 | 6 | # Errors 7 | 8 | @type reason :: atom | charlist | tuple | binary 9 | 10 | @type error_tuple :: {:error, reason} 11 | 12 | # Data Types 13 | 14 | @type host :: charlist | ip_address | :loopback 15 | 16 | @type hostname :: atom | charlist 17 | 18 | @type ip_address :: ip4_address | ip6_address 19 | 20 | @type ip4_address :: {0..255, 0..255, 0..255, 0..255} 21 | 22 | @type ip6_address :: 23 | {0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535, 0..65535} 24 | 25 | @type port_number :: 0..65535 26 | 27 | @type channel_pid :: pid 28 | 29 | @type handle :: term() 30 | 31 | @type data :: charlist | binary 32 | 33 | @type either_string :: binary() | charlist() 34 | 35 | # Default is bof - beginning of file | cur - current cursor position | eof - end of file 36 | # You can specify where you want to place the cursor 37 | @type location :: integer() | {:bof, integer()} | {:cur, integer()} | {:eof, integer()} 38 | 39 | # 'creat' short for 'creatine' I suspect... you can use it to create a file too though 40 | @type mode :: [:read | :write | :creat | :trunc | :append | :binary | :raw] 41 | 42 | @type file_type :: :device | :directory | :regular | :other | :symlink 43 | 44 | # file_info{size = integer() >= 0 | undefined, 45 | # type = 46 | # device | directory | other | regular | 47 | # symlink | undefined, 48 | # access = 49 | # read | write | read_write | none | undefined, 50 | # atime = 51 | # file:date_time() | 52 | # integer() >= 0 | 53 | # undefined, 54 | # mtime = 55 | # file:date_time() | 56 | # integer() >= 0 | 57 | # undefined, 58 | # ctime = 59 | # file:date_time() | 60 | # integer() >= 0 | 61 | # undefined, 62 | # mode = integer() >= 0 | undefined, 63 | # links = integer() >= 0 | undefined, 64 | # major_device = integer() >= 0 | undefined, 65 | # minor_device = integer() >= 0 | undefined, 66 | # inode = integer() >= 0 | undefined, 67 | # uid = integer() >= 0 | undefined, 68 | # gid = integer() >= 0 | undefined} 69 | 70 | # To convert back and forth use File.Stat.from_record() and File.Stat.to_record() 71 | 72 | @type file_info :: :file.file_info() 73 | 74 | def new_file_info(opts \\ []) do 75 | { 76 | :file_info, 77 | opts[:size] || :undefined, 78 | opts[:type] || :undefined, 79 | opts[:access] || :undefined, 80 | opts[:atime] || :undefined, 81 | opts[:mtime] || :undefined, 82 | opts[:ctime] || :undefined, 83 | opts[:mode] || :undefined, 84 | opts[:links] || :undefined, 85 | opts[:major_device] || :undefined, 86 | opts[:minor_device] || :undefined, 87 | opts[:inode] || :undefined, 88 | opts[:uid] || :undefined, 89 | opts[:gid] || :undefined 90 | } 91 | end 92 | 93 | @doc """ 94 | Erlang likes charlists and Elixir likes binary strings 95 | Either one fed in gets turned into charlist for Erlang consumption 96 | """ 97 | @spec charlist(either_string) :: charlist 98 | def charlist(string) when is_binary(string), do: String.to_charlist(string) 99 | # Throw error if it doesn't fit 100 | def charlist(string) when is_list(string), do: string 101 | 102 | @spec charlist_or_handle(either_string) :: charlist | handle() 103 | def charlist_or_handle(string) when is_binary(string), do: String.to_charlist(string) 104 | # Super permissive as handle can be anything 105 | def charlist_or_handle(string), do: string 106 | end 107 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sftp_ex, 7 | version: "0.3.0", 8 | elixir: ">= 1.10.0", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | build_embedded: Mix.env() == :prod, 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | description: "A simple SFTP Elixir library", 14 | package: package(), 15 | # docs 16 | name: "sftp_ex", 17 | source_url: "https://github.com/mikejdorm/sftp_ex", 18 | # The main page in the docs 19 | docs: [main: "SftpEx", extras: ["README.md"]] 20 | ] 21 | end 22 | 23 | # Specifies which paths to compile per environment. 24 | defp elixirc_paths(:test), do: ["test/support", "lib"] 25 | defp elixirc_paths(_), do: ["lib"] 26 | 27 | def application do 28 | [extra_applications: [:logger, :ssh, :public_key, :crypto]] 29 | end 30 | 31 | defp deps do 32 | [ 33 | {:mox, "~> 1.0.1", only: :test}, 34 | {:ex_doc, "~> 0.14", only: :dev} 35 | ] 36 | end 37 | 38 | defp package do 39 | [ 40 | maintainers: ["Michael Dorman"], 41 | licenses: ["MIT"], 42 | links: %{github: "https://github.com/mikejdorm/sftp_ex"} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], [], "hexpm", "0fdcd651f9689e81cda24c8e5d06947c5aca69dbd8ce3d836b02bcd0c6004592"}, 3 | "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm", "5c30e436a5acfdc2fd8fe6866585fcaf30f434c611d8119d4f3390ced2a550f3"}, 4 | "meck": {:hex, :meck, "0.8.12", "1f7b1a9f5d12c511848fec26bbefd09a21e1432eadb8982d9a8aceb9891a3cf2", [:rebar3], [], "hexpm", "7a6ab35a42e6c846636e8ecd6fdf2cc2e3f09dbee1abb15c1a7c705c10775787"}, 5 | "mock": {:hex, :mock, "0.2.0", "5991877be6bb514b647dbd6f4869bc12bd7f2829df16e86c98d6108f966d34d7", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "f48c0db393124cb270bf45b3a44c8931604fa39f2dcd503c3af796a58c0985d7"}, 6 | "mox": {:hex, :mox, "1.0.1", "b651bf0113265cda0ba3a827fcb691f848b683c373b77e7d7439910a8d754d6e", [:mix], [], "hexpm", "35bc0dea5499d18db4ef7fe4360067a59b06c74376eb6ab3bd67e6295b133469"}, 7 | } 8 | -------------------------------------------------------------------------------- /sftp_ex.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /test/data/test_file.txt: -------------------------------------------------------------------------------- 1 | this is a test file.... -------------------------------------------------------------------------------- /test/sftp_ex/conn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.ConnTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: false 4 | 5 | import Mox 6 | 7 | alias SFTP.Connection, as: Conn 8 | 9 | @host "testhost" 10 | @port 22 11 | @opts [] 12 | 13 | test "disconnect" do 14 | channel_pid = :c.pid(0, 200, 0) 15 | channel_ref = :c.pid(0, 250, 0) 16 | 17 | conn = Conn.new(channel_pid, channel_ref, @host, @port, @opts) 18 | 19 | Mock.SftpEx.Erl.Sftp 20 | |> expect(:stop_channel, fn ^conn -> 21 | :ok 22 | end) 23 | 24 | Mock.SftpEx.Ssh |> expect(:close_connection, fn ^conn -> :ok end) 25 | 26 | assert :ok == Conn.disconnect(conn) 27 | end 28 | 29 | test "connect" do 30 | channel_pid = :c.pid(0, 200, 0) 31 | channel_ref = :c.pid(0, 250, 0) 32 | 33 | Mock.SftpEx.Erl.Sftp 34 | |> expect(:start_channel, fn @host, @port, @opts -> 35 | {:ok, channel_pid, channel_ref} 36 | end) 37 | 38 | Mock.SftpEx.Ssh |> expect(:start, fn -> :ok end) 39 | 40 | {:ok, connection} = Conn.connect(@host, @port, @opts) 41 | assert @host == connection.host 42 | assert @port == connection.port 43 | assert @opts == connection.opts 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/sftp_ex/sftp/access_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.AccessTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | 5 | import Mox 6 | 7 | alias SftpEx.Sftp.Access 8 | alias SftpEx.Types, as: T 9 | 10 | @host "testhost" 11 | @port 22 12 | @opts [] 13 | @test_connection SFTP.Connection.new(self(), self(), @host, @port, @opts) 14 | 15 | test "open normal file" do 16 | Mock.SftpEx.Erl.Sftp 17 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 18 | {:ok, T.new_file_info()} 19 | end) 20 | 21 | Mock.SftpEx.Erl.Sftp 22 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:read, :binary], _timeout -> 23 | {:ok, {:a, :b, :c}} 24 | end) 25 | 26 | {:ok, handle} = Access.open(@test_connection, "test/data/test_file.txt", [:read, :binary]) 27 | assert :erlang.binary_to_term(binary_data()) == handle 28 | end 29 | 30 | @tag capture_log: true 31 | test "open non-existent file" do 32 | Mock.SftpEx.Erl.Sftp 33 | |> expect(:read_file_info, fn _conn, 'bad_file.txt', _timeout -> 34 | {:error, "No Such Path"} 35 | end) 36 | 37 | Mock.SftpEx.Erl.Sftp 38 | |> expect(:open, fn _conn, 'bad_file.txt', [:read, :binary], _timeout -> 39 | {:ok, {:a, :b, :c}} 40 | end) 41 | 42 | e = Access.open(@test_connection, "bad_file.txt", [:read, :binary]) 43 | assert {:error, "No Such Path"} == e 44 | end 45 | 46 | test "open_directory returns handle to directory" do 47 | Mock.SftpEx.Erl.Sftp 48 | |> expect(:open_directory, fn _conn, 'test/data', _timeout -> 49 | {:ok, :handle} 50 | end) 51 | 52 | Mock.SftpEx.Erl.Sftp 53 | |> expect(:read_file_info, fn _conn, 'test/data', _timeout -> 54 | {:ok, T.new_file_info()} 55 | end) 56 | 57 | Mock.SftpEx.Erl.Sftp 58 | |> expect(:open, fn _conn, 'test/data', [:read, :binary], _timeout -> 59 | {:ok, {:a, :b, :c}} 60 | end) 61 | 62 | {:ok, handle} = Access.open(@test_connection, "test/data", [:read, :binary]) 63 | 64 | assert :erlang.binary_to_term(binary_data()) == handle 65 | end 66 | 67 | test "close file" do 68 | Mock.SftpEx.Erl.Sftp 69 | |> expect(:close, fn _conn, {:yo, :handle, :can, [be: "anything"]}, _timeout -> 70 | :ok 71 | end) 72 | 73 | assert :ok == 74 | Access.close( 75 | @test_connection, 76 | {:yo, :handle, :can, [be: "anything"]}, 77 | "test/data/test_file.txt" 78 | ) 79 | end 80 | 81 | @tag capture_log: true 82 | test "close non-existent file" do 83 | Mock.SftpEx.Erl.Sftp 84 | |> expect(:close, fn _conn, "handle", _timeout -> 85 | {:error, "Error closing file"} 86 | end) 87 | 88 | assert {:error, "Error closing file"} == Access.close(@test_connection, "handle") 89 | end 90 | 91 | # {:a, :b, :c} in binary 92 | def binary_data do 93 | <<131, 104, 3, 100, 0, 1, 97, 100, 0, 1, 98, 100, 0, 1, 99>> 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/sftp_ex/sftp/management_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.ManagementTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: false 4 | 5 | import Mox 6 | 7 | alias SFTP.Connection, as: Conn 8 | alias SftpEx.Sftp.Management 9 | alias SftpEx.Types, as: T 10 | 11 | @host "testhost" 12 | @port 22 13 | @opts [] 14 | @test_connection Conn.new(self(), self(), @host, @port, @opts) 15 | @timeout 2000 16 | 17 | test "make directory" do 18 | Mock.SftpEx.Erl.Sftp 19 | |> expect(:make_directory, fn _conn, 'test/data', _timeout -> 20 | :ok 21 | end) 22 | 23 | assert :ok == Management.make_directory(@test_connection, "test/data", @timeout) 24 | end 25 | 26 | test "remove directory" do 27 | Mock.SftpEx.Erl.Sftp 28 | |> expect(:delete, fn _conn, 'test/data/test_file.txt', _timeout -> 29 | :ok 30 | end) 31 | 32 | Mock.SftpEx.Erl.Sftp 33 | |> expect(:list_dir, fn _conn, 'test/data', _timeout -> 34 | {:ok, ['test_file.txt']} 35 | end) 36 | 37 | Mock.SftpEx.Erl.Sftp 38 | |> expect(:read_file_info, fn _conn, 'test/data', _timeout -> 39 | {:ok, T.new_file_info(type: :directory)} 40 | end) 41 | 42 | Mock.SftpEx.Erl.Sftp 43 | |> expect(:delete_directory, fn _conn, 'test/data', _timeout -> 44 | :ok 45 | end) 46 | 47 | assert :ok == Management.remove_directory(@test_connection, "test/data", @timeout) 48 | end 49 | 50 | test "remove non-existent directory" do 51 | Mock.SftpEx.Erl.Sftp 52 | |> expect(:list_dir, fn _conn, 'baddir', _timeout -> 53 | {:error, "No Such Path"} 54 | end) 55 | 56 | Mock.SftpEx.Erl.Sftp 57 | |> expect(:read_file_info, fn _conn, 'baddir', _timeout -> 58 | # This will not return as list_dir errors 59 | {:ok, T.new_file_info(type: :directory)} 60 | end) 61 | 62 | Mock.SftpEx.Erl.Sftp 63 | |> expect(:delete_directory, fn _conn, 'baddir', _timeout -> 64 | # This will not return as list_dir errors 65 | :ok 66 | end) 67 | 68 | assert {:error, "No Such Path"} == 69 | Management.remove_directory(@test_connection, "baddir", @timeout) 70 | end 71 | 72 | test "remove file" do 73 | Mock.SftpEx.Erl.Sftp 74 | |> expect(:delete, fn _conn, 'test/test_file.txt', _timeout -> 75 | :ok 76 | end) 77 | 78 | assert :ok == Management.remove_file(@test_connection, "test/test_file.txt", @timeout) 79 | end 80 | 81 | test "remove non-existent file" do 82 | Mock.SftpEx.Erl.Sftp 83 | |> expect(:delete, fn _conn, 'bad-file.txt', _timeout -> 84 | {:error, "Error deleting file"} 85 | end) 86 | 87 | assert {:error, "Error deleting file"} == 88 | Management.remove_file(@test_connection, "bad-file.txt", @timeout) 89 | end 90 | 91 | test "rename directory" do 92 | Mock.SftpEx.Erl.Sftp 93 | |> expect(:rename, fn _conn, 'test/data/test_file.txt', 'test/data/test_file2.txt', _timeout -> 94 | :ok 95 | end) 96 | 97 | assert :ok == 98 | Management.rename( 99 | @test_connection, 100 | "test/data/test_file.txt", 101 | "test/data/test_file2.txt", 102 | @timeout 103 | ) 104 | end 105 | 106 | test "rename non-existent directory" do 107 | Mock.SftpEx.Erl.Sftp 108 | |> expect(:rename, fn _conn, 'bad-file.txt', 'bad-file2.txt', _timeout -> 109 | {:error, "File not found"} 110 | end) 111 | 112 | assert {:error, "File not found"} == 113 | Management.rename(@test_connection, "bad-file.txt", "bad-file2.txt") 114 | end 115 | 116 | test "append to existing file" do 117 | Mock.SftpEx.Erl.Sftp 118 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 119 | {:ok, T.new_file_info(size: 100, type: :regular)} 120 | end) 121 | 122 | Mock.SftpEx.Erl.Sftp 123 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:append], _timeout -> 124 | {:ok, {:a, :b, :c}} 125 | end) 126 | 127 | Mock.SftpEx.Erl.Sftp 128 | |> expect(:pwrite, fn _conn, {:a, :b, :c}, 100, "what up", _timeout -> 129 | :ok 130 | end) 131 | 132 | Mock.SftpEx.Erl.Sftp 133 | |> expect(:stop_channel, fn _conn -> 134 | :ok 135 | end) 136 | 137 | assert :ok == Management.append_file(@test_connection, "test/data/test_file.txt", "what up") 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/sftp_ex/sftp/stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.StreamTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: false 4 | 5 | import Mox 6 | 7 | alias SFTP.Connection, as: Conn 8 | alias SftpEx.Sftp 9 | 10 | @host "testhost" 11 | @port 22 12 | @opts [] 13 | @test_connection Conn.new(self(), self(), @host, @port, @opts) 14 | 15 | describe "into/1" do 16 | test "without errors" do 17 | Mock.SftpEx.Erl.Sftp 18 | |> expect(:write, 10, fn _conn, {:a, :b, :c}, int, _timeout when int in 1..10 -> 19 | :ok 20 | end) 21 | 22 | Mock.SftpEx.Erl.Sftp 23 | |> expect(:close, fn _conn, {:a, :b, :c}, _timeout -> 24 | :ok 25 | end) 26 | 27 | Mock.SftpEx.Erl.Sftp 28 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:write, :binary, :creat], _timeout -> 29 | {:ok, {:a, :b, :c}} 30 | end) 31 | 32 | assert :ok == 33 | 1..10 34 | |> Stream.into(Sftp.Stream.new(@test_connection, 'test/data/test_file.txt', 1064)) 35 | |> Stream.run() 36 | end 37 | 38 | test "with errors" do 39 | Mock.SftpEx.Erl.Sftp 40 | |> expect(:write, 10, fn _conn, {:a, :b, :c}, int, _timeout when int in 1..10 -> 41 | :ok 42 | end) 43 | 44 | Mock.SftpEx.Erl.Sftp 45 | |> expect(:close, fn _conn, {:a, :b, :c}, _timeout -> 46 | :ok 47 | end) 48 | 49 | Mock.SftpEx.Erl.Sftp 50 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:write, :binary, :creat], _timeout -> 51 | {:ok, {:a, :b, :c}} 52 | end) 53 | 54 | assert :ok == 55 | 1..10 56 | |> Stream.into(Sftp.Stream.new(@test_connection, 'test/data/test_file.txt', 1064)) 57 | |> Stream.run() 58 | end 59 | end 60 | 61 | describe "reduce/3" do 62 | test "without errors" do 63 | Mock.SftpEx.Erl.Sftp 64 | |> expect(:write, 20, fn _conn, {:a, :b, :c}, int, _timeout when int in 1..10 -> 65 | :ok 66 | end) 67 | 68 | Mock.SftpEx.Erl.Sftp 69 | |> expect(:close, 2, fn _conn, {:a, :b, :c}, _timeout -> 70 | :ok 71 | end) 72 | 73 | Mock.SftpEx.Erl.Sftp 74 | |> expect(:open, 2, fn _conn, 75 | 'test/data/test_file.txt', 76 | [:write, :binary, :creat], 77 | _timeout -> 78 | {:ok, {:a, :b, :c}} 79 | end) 80 | 81 | # Complete the stream 82 | assert 1..10 83 | |> Stream.into(Sftp.Stream.new(@test_connection, 'test/data/test_file.txt', 1064)) 84 | |> Enumerable.reduce({:cont, %{}}, fn 85 | count, acc -> 86 | {:cont, acc |> Map.put(count, count)} 87 | end) == 88 | {:done, 89 | %{ 90 | 1 => 1, 91 | 2 => 2, 92 | 3 => 3, 93 | 4 => 4, 94 | 5 => 5, 95 | 6 => 6, 96 | 7 => 7, 97 | 8 => 8, 98 | 9 => 9, 99 | 10 => 10 100 | }} 101 | 102 | # Halt the stream 103 | assert 1..10 104 | |> Stream.into(Sftp.Stream.new(@test_connection, 'test/data/test_file.txt', 1064)) 105 | |> Enumerable.reduce({:cont, %{}}, fn 106 | 9, acc -> 107 | {:halt, acc} 108 | 109 | count, acc -> 110 | {:cont, acc |> Map.put(count, count)} 111 | end) == 112 | {:halted, 113 | %{ 114 | 1 => 1, 115 | 2 => 2, 116 | 3 => 3, 117 | 4 => 4, 118 | 5 => 5, 119 | 6 => 6, 120 | 7 => 7, 121 | 8 => 8 122 | }} 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/sftp_ex/sftp/transfer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Sftp.TransferTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: false 4 | 5 | import Mox 6 | 7 | alias SFTP.Connection, as: Conn 8 | alias SftpEx.Sftp.Access 9 | alias SftpEx.Sftp.Transfer 10 | alias SftpEx.Types, as: T 11 | 12 | @host "testhost" 13 | @port 22 14 | @opts [] 15 | @test_connection Conn.new(self(), self(), @host, @port, @opts) 16 | 17 | describe "each_binstream/3" do 18 | test "without errors" do 19 | Mock.SftpEx.Erl.Sftp 20 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 21 | {:ok, T.new_file_info()} 22 | end) 23 | 24 | Mock.SftpEx.Erl.Sftp 25 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:read, :binary], _timeout -> 26 | {:ok, {:a, :b, :c}} 27 | end) 28 | 29 | Mock.SftpEx.Erl.Sftp 30 | |> expect(:read, fn _conn, {:a, :b, :c}, 1024, _timeout -> 31 | data = "run for the hills" 32 | {:ok, data} 33 | end) 34 | 35 | assert {:ok, handle} = 36 | Access.open(@test_connection, "test/data/test_file.txt", [:read, :binary]) 37 | 38 | assert {["run for the hills"], ^handle} = 39 | Transfer.each_binstream(@test_connection, handle, 1024) 40 | end 41 | 42 | test "with eof" do 43 | Mock.SftpEx.Erl.Sftp 44 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 45 | {:ok, T.new_file_info()} 46 | end) 47 | 48 | Mock.SftpEx.Erl.Sftp 49 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:read, :binary], _timeout -> 50 | {:ok, {:a, :b, :c}} 51 | end) 52 | 53 | Mock.SftpEx.Erl.Sftp 54 | |> expect(:read, fn _conn, {:a, :b, :c}, 1024, _timeout -> 55 | :eof 56 | end) 57 | 58 | assert {:ok, handle} = 59 | Access.open(@test_connection, "test/data/test_file.txt", [:read, :binary]) 60 | 61 | assert {:halt, ^handle} = Transfer.each_binstream(@test_connection, handle, 1024) 62 | end 63 | 64 | test "with streaming error" do 65 | Mock.SftpEx.Erl.Sftp 66 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 67 | {:ok, T.new_file_info()} 68 | end) 69 | 70 | Mock.SftpEx.Erl.Sftp 71 | |> expect(:open, fn _conn, 'test/data/test_file.txt', [:read, :binary], _timeout -> 72 | {:ok, {:a, :b, :c}} 73 | end) 74 | 75 | Mock.SftpEx.Erl.Sftp 76 | |> expect(:read, fn _conn, {:a, :b, :c}, 1024, _timeout -> 77 | {:error, "I fell off the roof"} 78 | end) 79 | 80 | assert {:ok, handle} = 81 | Access.open(@test_connection, "test/data/test_file.txt", [:read, :binary]) 82 | 83 | assert_raise IO.StreamError, fn -> 84 | Transfer.each_binstream(@test_connection, handle, 1024) 85 | end 86 | end 87 | end 88 | 89 | describe "download/3" do 90 | test "a file" do 91 | Mock.SftpEx.Erl.Sftp 92 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 93 | {:ok, T.new_file_info(type: :regular)} 94 | end) 95 | 96 | Mock.SftpEx.Erl.Sftp 97 | |> expect(:read_file, fn _conn, 'test/data/test_file.txt', _timeout -> 98 | {:ok, "some like it hot"} 99 | end) 100 | 101 | assert ["some like it hot"] = Transfer.download(@test_connection, "test/data/test_file.txt") 102 | end 103 | 104 | test "a directory" do 105 | Mock.SftpEx.Erl.Sftp 106 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 107 | {:ok, T.new_file_info(type: :directory)} 108 | end) 109 | 110 | Mock.SftpEx.Erl.Sftp 111 | |> expect(:read_file, fn _conn, 'test/data/test_file1.txt', _timeout -> 112 | {:ok, "some like it hot"} 113 | end) 114 | 115 | Mock.SftpEx.Erl.Sftp 116 | |> expect(:read_file, fn _conn, 'test/data/test_file2.txt', _timeout -> 117 | {:ok, "some like it cold"} 118 | end) 119 | 120 | Mock.SftpEx.Erl.Sftp 121 | |> expect(:list_dir, fn _conn, 'test/data/test_file.txt', _timeout -> 122 | {:ok, ['test/data/test_file1.txt', 'test/data/test_file2.txt']} 123 | end) 124 | 125 | assert [["some like it hot"], ["some like it cold"]] = 126 | Transfer.download(@test_connection, "test/data/test_file.txt") 127 | end 128 | 129 | test "not a directory or a file" do 130 | Mock.SftpEx.Erl.Sftp 131 | |> expect(:read_file_info, fn _conn, 'test/data/test_file.txt', _timeout -> 132 | {:ok, T.new_file_info(type: :symlink)} 133 | end) 134 | 135 | assert {:error, "Unsupported type: :symlink"} = 136 | Transfer.download(@test_connection, "test/data/test_file.txt") 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/support/mock/sftp/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Erl.Sftp.Behaviour do 2 | @moduledoc """ 3 | Use separate module for ease of testing 4 | """ 5 | 6 | alias SFTP.Connection, as: Conn 7 | alias SftpEx.Types, as: T 8 | 9 | @callback start_channel(T.host(), T.port_number(), list) :: 10 | {:ok, T.channel_pid(), :ssh.connection_ref()} | T.error_tuple() 11 | 12 | @callback stop_channel(Conn.t()) :: :ok 13 | 14 | @callback rename(Conn.t(), charlist, charlist, timeout) :: :ok | T.error_tuple() 15 | 16 | @callback read_file_info(Conn.t(), charlist, timeout) :: {:ok, T.file_info()} | T.error_tuple() 17 | 18 | @callback list_dir(Conn.t(), charlist, timeout) :: {:ok, [charlist]} | T.error_tuple() 19 | 20 | @callback delete(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 21 | 22 | @callback delete_directory(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 23 | 24 | @callback make_directory(Conn.t(), charlist, timeout) :: :ok | T.error_tuple() 25 | 26 | @callback close(Conn.t(), T.handle(), timeout) :: :ok | T.error_tuple() 27 | 28 | @callback open(Conn.t(), list, atom, timeout) :: {:ok, binary} | T.error_tuple() 29 | 30 | @callback open_directory(Conn.t(), list, timeout) :: {:ok, T.handle()} | T.error_tuple() 31 | 32 | @callback read(Conn.t(), T.handle(), non_neg_integer, timeout) :: 33 | {:ok, T.data()} | :eof | T.error_tuple() 34 | 35 | @callback write(Conn.t(), T.handle(), iodata, timeout) :: :ok | T.error_tuple() 36 | 37 | @callback write_file(Conn.t(), charlist, iodata, timeout) :: :ok | T.error_tuple() 38 | 39 | @callback read_file(Conn.t(), charlist, timeout) :: {:ok, T.data()} | T.error_tuple() 40 | 41 | @callback position(Conn.t(), T.handle(), T.location(), timeout) :: 42 | {:ok, non_neg_integer()} | T.error_tuple() 43 | 44 | @callback pread(Conn.t(), T.handle(), non_neg_integer(), non_neg_integer(), timeout) :: 45 | {:ok, T.data()} | :eof | T.error_tuple() 46 | 47 | @callback pwrite(Conn.t(), T.handle(), non_neg_integer(), T.data(), timeout) :: 48 | :ok | T.error_tuple() 49 | end 50 | -------------------------------------------------------------------------------- /test/support/mock/ssh/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule SftpEx.Ssh.Behaviour do 2 | @moduledoc false 3 | # a contract for ssh to fufil, for Mox 4 | @callback start() :: :ok | {:error, any()} 5 | @callback close_connection(Conn.t()) :: :ok | {:error, any()} 6 | end 7 | -------------------------------------------------------------------------------- /test/support/mocks.ex: -------------------------------------------------------------------------------- 1 | Mox.defmock(Mock.SftpEx.Ssh, for: SftpEx.Ssh.Behaviour) 2 | Mox.defmock(Mock.SftpEx.Erl.Sftp, for: SftpEx.Erl.Sftp.Behaviour) 3 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------