From 4d31c96e03fa6fc6d3ffdb4131c6e3d1455aef2c Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 11 Oct 2024 16:47:01 +0200 Subject: [PATCH 1/2] Add a bunch more SFTP wrappers --- docs/src/changelog.md | 2 +- docs/src/sftp.md | 38 +++- gen/gen.jl | 3 +- src/bindings.jl | 72 ++++++-- src/sftp.jl | 414 ++++++++++++++++++++++++++++++++---------- src/utils.jl | 2 + test/LibSSHTests.jl | 111 ++++++++++- 7 files changed, 529 insertions(+), 113 deletions(-) diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 157b1f3..20da1c6 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -19,7 +19,7 @@ Changelog](https://keepachangelog.com). - [`close(::SshChannel)`](@ref) and [`closewrite(::SshChannel)`](@ref) now support an `allow_fail` argument that will print a warning instead of throw an exception if modifying the `lib.ssh_channel` fails ([#16]). -- Basic [SFTP](sftp.md) support. +- Initial [SFTP](sftp.md) client support ([#16], [#18], [#19]). ### Fixed diff --git a/docs/src/sftp.md b/docs/src/sftp.md index e951203..a83e1f7 100644 --- a/docs/src/sftp.md +++ b/docs/src/sftp.md @@ -37,8 +37,11 @@ Base.close(::SftpSession) Base.isopen(::SftpSession) Base.lock(::SftpSession) Base.unlock(::SftpSession) -Base.stat(::String, ::SftpSession) Base.readdir(::AbstractString, ::SftpSession) +Base.rm(::AbstractString, ::SftpSession) +Base.mkdir(::AbstractString, ::SftpSession) +Base.mv(::AbstractString, ::AbstractString, ::SftpSession) +Base.stat(::String, ::SftpSession) get_extensions(::SftpSession) get_limits(::SftpSession) get_error(::SftpSession) @@ -66,8 +69,39 @@ Base.seekstart(::SftpFile) Base.seekend(::SftpFile) ``` +## File type helpers + +```@docs +Base.ispath(::AbstractString, ::SftpSession) +Base.ispath(::SftpAttributes) +Base.isdir(::AbstractString, ::SftpSession) +Base.isdir(::SftpAttributes) +Base.isfile(::AbstractString, ::SftpSession) +Base.isfile(::SftpAttributes) +Base.issocket(::AbstractString, ::SftpSession) +Base.issocket(::SftpAttributes) +Base.islink(::AbstractString, ::SftpSession) +Base.islink(::SftpAttributes) +Base.isblockdev(::AbstractString, ::SftpSession) +Base.isblockdev(::SftpAttributes) +Base.ischardev(::AbstractString, ::SftpSession) +Base.ischardev(::SftpAttributes) +Base.isfifo(::AbstractString, ::SftpSession) +Base.isfifo(::SftpAttributes) +``` + ## Other types ```@docs -SftpError SftpAttributes +SftpError +SftpException ``` + +When an [`SftpException`](@ref) is printed it will be displayed like this: +```@repl +import LibSSH as ssh +ssh.SftpException("Failure", "/tmp/foo", ssh.SftpError_GenericFailure, "SFTP failed", "foo@bar") +``` + +Note that the SFTP subsystem doesn't always set an error on the +[`Session`](@ref), so take the `Session error` with a grain of salt. diff --git a/gen/gen.jl b/gen/gen.jl index 4836bcc..f7f44e8 100644 --- a/gen/gen.jl +++ b/gen/gen.jl @@ -27,7 +27,8 @@ ssh_ok_functions = [:ssh_message_auth_reply_success, :ssh_message_auth_set_metho threadcall_functions = [:sftp_new, :sftp_init, :sftp_open, :sftp_close, :sftp_home_directory, :sftp_stat, :sftp_aio_wait_read, :sftp_aio_wait_write, - :sftp_opendir, :sftp_readdir, :sftp_closedir] + :sftp_opendir, :sftp_readdir, :sftp_closedir, + :sftp_unlink, :sftp_rmdir, :sftp_mkdir, :sftp_rename] all_rewritable_functions = vcat(string_functions, bool_functions, ssh_ok_functions, threadcall_functions) """ diff --git a/src/bindings.jl b/src/bindings.jl index ddab294..b5ddeb7 100644 --- a/src/bindings.jl +++ b/src/bindings.jl @@ -3637,8 +3637,19 @@ function sftp_rewind(file) @ccall libssh.sftp_rewind(file::sftp_file)::Cvoid end +function _threadcall_sftp_unlink(sftp::sftp_session, file::Ptr{Cchar}) + gc_state = @ccall(jl_gc_safe_enter()::Int8) + ret = @ccall(libssh.sftp_unlink(sftp::sftp_session, file::Ptr{Cchar})::Cint) + @ccall jl_gc_safe_leave(gc_state::Int8)::Cvoid + return ret +end + """ - sftp_unlink(sftp, file) + sftp_unlink(sftp::sftp_session, file::Ptr{Cchar}) + +Auto-generated wrapper around `sftp_unlink()`. Original upstream documentation is below. + +--- Unlink (delete) a file. @@ -3650,12 +3661,24 @@ Unlink (delete) a file. # See also [`sftp_get_error`](@ref)() """ -function sftp_unlink(sftp, file) - @ccall libssh.sftp_unlink(sftp::sftp_session, file::Ptr{Cchar})::Cint +function sftp_unlink(sftp::sftp_session, file::Ptr{Cchar}) + cfunc = @cfunction(_threadcall_sftp_unlink, Cint, (sftp_session, Ptr{Cchar})) + return @threadcall(cfunc, Cint, (sftp_session, Ptr{Cchar}), sftp, file) +end + +function _threadcall_sftp_rmdir(sftp::sftp_session, directory::Ptr{Cchar}) + gc_state = @ccall(jl_gc_safe_enter()::Int8) + ret = @ccall(libssh.sftp_rmdir(sftp::sftp_session, directory::Ptr{Cchar})::Cint) + @ccall jl_gc_safe_leave(gc_state::Int8)::Cvoid + return ret end """ - sftp_rmdir(sftp, directory) + sftp_rmdir(sftp::sftp_session, directory::Ptr{Cchar}) + +Auto-generated wrapper around `sftp_rmdir()`. Original upstream documentation is below. + +--- Remove a directory. @@ -3667,12 +3690,24 @@ Remove a directory. # See also [`sftp_get_error`](@ref)() """ -function sftp_rmdir(sftp, directory) - @ccall libssh.sftp_rmdir(sftp::sftp_session, directory::Ptr{Cchar})::Cint +function sftp_rmdir(sftp::sftp_session, directory::Ptr{Cchar}) + cfunc = @cfunction(_threadcall_sftp_rmdir, Cint, (sftp_session, Ptr{Cchar})) + return @threadcall(cfunc, Cint, (sftp_session, Ptr{Cchar}), sftp, directory) +end + +function _threadcall_sftp_mkdir(sftp::sftp_session, directory::Ptr{Cchar}, mode::mode_t) + gc_state = @ccall(jl_gc_safe_enter()::Int8) + ret = @ccall(libssh.sftp_mkdir(sftp::sftp_session, directory::Ptr{Cchar}, mode::mode_t)::Cint) + @ccall jl_gc_safe_leave(gc_state::Int8)::Cvoid + return ret end """ - sftp_mkdir(sftp, directory, mode) + sftp_mkdir(sftp::sftp_session, directory::Ptr{Cchar}, mode::mode_t) + +Auto-generated wrapper around `sftp_mkdir()`. Original upstream documentation is below. + +--- Create a directory. @@ -3685,12 +3720,24 @@ Create a directory. # See also [`sftp_get_error`](@ref)() """ -function sftp_mkdir(sftp, directory, mode) - @ccall libssh.sftp_mkdir(sftp::sftp_session, directory::Ptr{Cchar}, mode::mode_t)::Cint +function sftp_mkdir(sftp::sftp_session, directory::Ptr{Cchar}, mode::mode_t) + cfunc = @cfunction(_threadcall_sftp_mkdir, Cint, (sftp_session, Ptr{Cchar}, mode_t)) + return @threadcall(cfunc, Cint, (sftp_session, Ptr{Cchar}, mode_t), sftp, directory, mode) +end + +function _threadcall_sftp_rename(sftp::sftp_session, original::Ptr{Cchar}, newname::Ptr{Cchar}) + gc_state = @ccall(jl_gc_safe_enter()::Int8) + ret = @ccall(libssh.sftp_rename(sftp::sftp_session, original::Ptr{Cchar}, newname::Ptr{Cchar})::Cint) + @ccall jl_gc_safe_leave(gc_state::Int8)::Cvoid + return ret end """ - sftp_rename(sftp, original, newname) + sftp_rename(sftp::sftp_session, original::Ptr{Cchar}, newname::Ptr{Cchar}) + +Auto-generated wrapper around `sftp_rename()`. Original upstream documentation is below. + +--- Rename or move a file or directory. @@ -3703,8 +3750,9 @@ Rename or move a file or directory. # See also [`sftp_get_error`](@ref)() """ -function sftp_rename(sftp, original, newname) - @ccall libssh.sftp_rename(sftp::sftp_session, original::Ptr{Cchar}, newname::Ptr{Cchar})::Cint +function sftp_rename(sftp::sftp_session, original::Ptr{Cchar}, newname::Ptr{Cchar}) + cfunc = @cfunction(_threadcall_sftp_rename, Cint, (sftp_session, Ptr{Cchar}, Ptr{Cchar})) + return @threadcall(cfunc, Cint, (sftp_session, Ptr{Cchar}, Ptr{Cchar}), sftp, original, newname) end """ diff --git a/src/sftp.jl b/src/sftp.jl index a2eedda..f51470d 100644 --- a/src/sftp.jl +++ b/src/sftp.jl @@ -37,6 +37,83 @@ does not indicate an error. end +## SftpAttributes + + +""" +$(TYPEDEF) + +Attributes of remote file objects. This has the following (read-only) properties: +- `name::String` +- `longname::String` +- `flags::UInt32` +- `type::UInt8` +- `size::UInt64` +- `uid::UInt32` +- `gid::UInt32` +- `owner::String` +- `group::String` +- `permissions::UInt32` +- `atime64::UInt64` +- `atime::UInt32` +- `atime_nseconds::UInt32` +- `createtime::UInt64` +- `createtime_nseconds::UInt32` +- `mtime64::UInt64` +- `mtime::UInt32` +- `mtime_nseconds::UInt32` +- `acl::String` +- `extended_count::UInt32` +- `extended_type::String` +- `extended_data::String` +""" +mutable struct SftpAttributes + ptr::Union{lib.sftp_attributes, Nothing} + + function SftpAttributes(ptr::lib.sftp_attributes) + self = new(ptr) + finalizer(close, self) + end +end + +Base.isassigned(attrs::SftpAttributes) = !isnothing(attrs.ptr) + +function Base.close(attrs::SftpAttributes) + if isassigned(attrs) + lib.sftp_attributes_free(attrs.ptr) + attrs.ptr = nothing + end +end + +function _show_attrs(io::IO, attrs::SftpAttributes) + mode = string(attrs.permissions, base=8, pad=6) + print(io, SftpAttributes, "(name='$(attrs.name)', size=$(attrs.size) bytes, owner=$(attrs.owner), permissions=0o$(mode))") +end + +Base.show(io::IO, attrs::SftpAttributes) = _show_attrs(io, attrs) + +function _load_attr(x::Ptr{Ptr{Cchar}}) + x = unsafe_load(x) + x == C_NULL ? "" : unsafe_string(Ptr{UInt8}(x)) +end + +function _load_attr(x::Ptr{lib.ssh_string}) + x = unsafe_load(x) + x == C_NULL ? "" : unsafe_string(Ptr{UInt8}(lib.ssh_string_get_char(x))) +end + +_load_attr(x) = unsafe_load(x) + +function Base.getproperty(attrs::SftpAttributes, name::Symbol) + if name in fieldnames(lib.sftp_attributes_struct) + ptr = getfield(attrs, :ptr) + _load_attr(getproperty(ptr, name)) + else + getfield(attrs, name) + end +end + + ## SftpSession @@ -226,7 +303,7 @@ Get the server limits. The returned object has the following fields: # Throws - `ArgumentError`: If `sftp` is closed. -- [`LibSSHException`](@ref): If getting the limits failed. +- [`SftpException`](@ref): If getting the limits failed. """ function get_limits(sftp::SftpSession) if !isassigned(sftp) @@ -235,7 +312,7 @@ function get_limits(sftp::SftpSession) ptr = lib.sftp_limits(sftp) if ptr == C_NULL - throw(LibSSHException("Couldn't get limits for $sftp")) + throw(SftpException("Couldn't get SFTP limits", sftp)) end limits = unsafe_load(ptr) @@ -244,6 +321,7 @@ function get_limits(sftp::SftpSession) return limits end +# Undocumented for now because it's difficult to test function Base.homedir(sftp::SftpSession, username=nothing) if !isassigned(sftp) throw(ArgumentError("The SftpSession doesn't have a valid pointer, cannot get the home directory")) @@ -260,8 +338,7 @@ function Base.homedir(sftp::SftpSession, username=nothing) end if ret == C_NULL - error_code = get_error(sftp) - throw(LibSSHException("Couldn't get the home directory: $(error_code)")) + throw(SftpException("Couldn't get the home directory", sftp)) end path = unsafe_string(Ptr{UInt8}(ret)) @@ -280,7 +357,7 @@ are the same as in `Base.readdir()` but only apply when `only_names=true`. # Throws - `ArgumentError`: If `sftp` is closed. -- [`LibSSHException`](@ref): If retrieving the directory contents failed. +- [`SftpException`](@ref): If retrieving the directory contents failed. """ function Base.readdir(dir::AbstractString, sftp::SftpSession; only_names=true, join::Bool=false, sort::Bool=true) @@ -296,8 +373,7 @@ function Base.readdir(dir::AbstractString, sftp::SftpSession; @lockandblock sftp.session lib.sftp_opendir(sftp.ptr, cstr) end if dir_ptr == C_NULL - error_code = get_error(sftp) - throw(LibSSHException("Couldn't open path $(dir) on $(sftp): $(error_code)")) + throw(SftpException("Couldn't open path", dir, sftp)) end # Read contents @@ -318,13 +394,13 @@ function Base.readdir(dir::AbstractString, sftp::SftpSession; # Close directory ret = @lockandblock sftp.session lib.sftp_closedir(dir_ptr) if ret == SSH_ERROR - throw(LibSSHException("Closing remote directory failed: $(ret)")) + throw(SftpException("Closing remote directory failed", dir, sftp)) end if only_names entry_names = [x.name for x in entries] if join - map!(x -> joinpath(dir, x), entry_names, entry_names) + map!(x -> _joinpath_linux(dir, x), entry_names, entry_names) end if sort sort!(entry_names) @@ -336,17 +412,158 @@ function Base.readdir(dir::AbstractString, sftp::SftpSession; end end +function _rmfile(path, sftp, force=false) + ret = GC.@preserve path begin + cstr = Base.unsafe_convert(Ptr{Cchar}, path) + @lockandblock sftp.session lib.sftp_unlink(sftp.ptr, cstr) + end + if ret != 0 + session = sftp.session + throw(SftpException("Couldn't delete file", path, sftp)) + end +end + +function _rmdir(path, sftp, recursive) + contents = readdir(path, sftp; only_names=false) + if !isempty(contents) + if recursive + for attrs in contents + rm(_joinpath_linux(path, attrs.name), sftp; attrs, recursive) + end + else + session = sftp.session + throw(Base.IOError("Cannot delete $(session.user)@$(session.host):$(path), directory not empty", Base.UV_ENOTEMPTY)) + end + end + + ret = GC.@preserve path begin + cstr = Base.unsafe_convert(Ptr{Cchar}, path) + @lockandblock sftp.session lib.sftp_rmdir(sftp.ptr, cstr) + end + if ret != 0 + throw(SftpException("Couldn't delete directory", path, sftp)) + end +end + """ $(TYPEDSIGNATURES) -Get information about the file object at `path`. +Delete remote file and directories. This has the same behaviour as `Base.rm()`, +and the `recursive` and `force` options mean the same thing. + +Internally the function will call [`Base.stat(::String, ::SftpSession)`](@ref) +to determine how to delete `path`, but if you already have the result of that it +can be passed to the `attrs` keyword argument to avoid the extra blocking call. + +# Throws +- `ArgumentError`: If `sftp` is closed. +- `Base.IOError`: If `path` is a non-empty directory and `recursive=false`. +- [`SftpException`](@ref) if deletion fails for some reason. +""" +function Base.rm(path::AbstractString, sftp::SftpSession; attrs=nothing, recursive=false, force=false) + if !isopen(sftp) + throw(ArgumentError("$(sftp) is closed, cannot use it to rm()")) + end + + if isnothing(attrs) + attrs = try + stat(path, sftp) + catch ex + if ex isa SftpException && ex.error_code == SftpError_NoSuchFile && force + return + else + rethrow() + end + end + end + + if isdir(attrs) + _rmdir(path, sftp, recursive) + else + _rmfile(path, sftp, force) + end + + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Make a remote directory. This behaves in exactly the same way as +`Base.mkdir()`. + +# Throws +- `ArgumentError`: If `sftp` is closed. +- [`SftpException`](@ref): If making the directory fails. +""" +function Base.mkdir(path::AbstractString, sftp::SftpSession; mode=0o777) + if !isopen(sftp) + throw(ArgumentError("$(sftp) is closed, cannot use it to mkdir()")) + end + + ret = GC.@preserve path begin + cstr = Base.unsafe_convert(Ptr{Cchar}, path) + @lockandblock sftp.session lib.sftp_mkdir(sftp.ptr, cstr, lib.mode_t(mode)) + end + if ret != 0 + throw(SftpException("Creating path failed", path, sftp)) + end + + return path +end + +""" +$(TYPEDSIGNATURES) + +Move `src` to `dst` remotely. Has the same behaviour as `Base.mv()`. + +# Throws +- `ArgumentError`: If `sftp` is closed. +- [`SftpException`](@ref): If the operation fails for some reason. +""" +function Base.mv(src::AbstractString, dst::AbstractString, sftp::SftpSession; force=false) + if !isopen(sftp) + throw(ArgumentError("$(sftp) is closed, cannot use it to mv()")) + end + + if force + attrs = nothing + try + attrs = stat(dst, sftp) + catch ex + if !(ex isa SftpException && ex.error_code == SftpError_NoSuchFile) + rethrow() + end + end + + if !isnothing(attrs) + rm(dst, sftp; attrs, recursive=true) + end + end + + ret = GC.@preserve src dst begin + src_ptr = Base.unsafe_convert(Ptr{Cchar}, src) + dst_ptr = Base.unsafe_convert(Ptr{Cchar}, dst) + @lockandblock sftp.session lib.sftp_rename(sftp.ptr, src_ptr, dst_ptr) + end + if ret != 0 + throw(SftpException("Renaming path to $(dst) failed", src, sftp)) + end + + return dst +end + +""" +$(TYPEDSIGNATURES) + +Get information about the file object at `path` as a [`SftpAttributes`](@ref). Note: the [`Demo.DemoServer`](@ref) does not support setting all of these properties. # Throws - `ArgumentError`: If `sftp` is closed. -- [`LibSSHException`](@ref): If retrieving the file object information failed +- [`SftpException`](@ref): If retrieving the file object information failed (e.g. if the path doesn't exist). """ function Base.stat(path::String, sftp::SftpSession) @@ -360,88 +577,67 @@ function Base.stat(path::String, sftp::SftpSession) end if ptr == C_NULL - error_code = get_error(sftp) - throw(LibSSHException("Couldn't stat '$path': $error_code")) + throw(SftpException("Couldn't stat path", path, sftp)) end SftpAttributes(ptr) end +_ismode(attrs, type) = (Filesystem.S_IFMT & attrs.permissions) == type +function _is_file_type(func, path, sftp) + attrs = nothing -## SftpAttributes - -""" -$(TYPEDEF) - -Attributes of remote file objects. This has the following (read-only) properties: -- `name::String` -- `longname::String` -- `flags::UInt32` -- `type::UInt8` -- `size::UInt64` -- `uid::UInt32` -- `gid::UInt32` -- `owner::String` -- `group::String` -- `permissions::UInt32` -- `atime64::UInt64` -- `atime::UInt32` -- `atime_nseconds::UInt32` -- `createtime::UInt64` -- `createtime_nseconds::UInt32` -- `mtime64::UInt64` -- `mtime::UInt32` -- `mtime_nseconds::UInt32` -- `acl::String` -- `extended_count::UInt32` -- `extended_type::String` -- `extended_data::String` -""" -mutable struct SftpAttributes - ptr::Union{lib.sftp_attributes, Nothing} - - function SftpAttributes(ptr::lib.sftp_attributes) - self = new(ptr) - finalizer(close, self) + try + attrs = stat(path, sftp) + catch ex + if !(ex isa SftpException + && ex.error_code in (SftpError_NoSuchFile, SftpError_NoSuchPath)) + rethrow() + end end + + isnothing(attrs) ? false : func(attrs) end -Base.isassigned(attrs::SftpAttributes) = !isnothing(attrs.ptr) +"$(TYPEDSIGNATURES)" +Base.ispath(path::AbstractString, sftp::SftpSession) = _is_file_type(ispath, path, sftp) +"$(TYPEDSIGNATURES)" +Base.ispath(::SftpAttributes) = true -function Base.close(attrs::SftpAttributes) - if isassigned(attrs) - lib.sftp_attributes_free(attrs.ptr) - attrs.ptr = nothing - end -end +"$(TYPEDSIGNATURES)" +Base.isdir(path::AbstractString, sftp::SftpSession) = _is_file_type(isdir, path, sftp) +"$(TYPEDSIGNATURES)" +Base.isdir(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFDIR) -function _show_attrs(io::IO, attrs::SftpAttributes) - mode = string(attrs.permissions, base=8, pad=6) - print(io, SftpAttributes, "(name='$(attrs.name)', size=$(attrs.size) bytes, owner=$(attrs.owner), permissions=0o$(mode))") -end +"$(TYPEDSIGNATURES)" +Base.isfile(path::AbstractString, sftp::SftpSession) = _is_file_type(isfile, path, sftp) +"$(TYPEDSIGNATURES)" +Base.isfile(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFREG) -Base.show(io::IO, attrs::SftpAttributes) = _show_attrs(io, attrs) +"$(TYPEDSIGNATURES)" +Base.issocket(path::AbstractString, sftp::SftpSession) = _is_file_type(issocket, path, sftp) +"$(TYPEDSIGNATURES)" +Base.issocket(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFSOCK) -function _load_attr(x::Ptr{Ptr{Cchar}}) - x = unsafe_load(x) - x == C_NULL ? "" : unsafe_string(Ptr{UInt8}(x)) -end +"$(TYPEDSIGNATURES)" +Base.islink(path::AbstractString, sftp::SftpSession) = _is_file_type(islink, path, sftp) +"$(TYPEDSIGNATURES)" +Base.islink(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFLNK) -function _load_attr(x::Ptr{lib.ssh_string}) - x = unsafe_load(x) - x == C_NULL ? "" : unsafe_string(Ptr{UInt8}(lib.ssh_string_get_char(x))) -end +"$(TYPEDSIGNATURES)" +Base.isblockdev(path::AbstractString, sftp::SftpSession) = _is_file_type(isblockdev, path, sftp) +"$(TYPEDSIGNATURES)" +Base.isblockdev(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFBLK) -_load_attr(x) = unsafe_load(x) +"$(TYPEDSIGNATURES)" +Base.ischardev(path::AbstractString, sftp::SftpSession) = _is_file_type(ischardev, path, sftp) +"$(TYPEDSIGNATURES)" +Base.ischardev(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFCHR) -function Base.getproperty(attrs::SftpAttributes, name::Symbol) - if name in fieldnames(lib.sftp_attributes_struct) - ptr = getfield(attrs, :ptr) - _load_attr(getproperty(ptr, name)) - else - getfield(attrs, name) - end -end +"$(TYPEDSIGNATURES)" +Base.isfifo(path::AbstractString, sftp::SftpSession) = _is_file_type(isfifo, path, sftp) +"$(TYPEDSIGNATURES)" +Base.isfifo(attrs::SftpAttributes) = _ismode(attrs, Filesystem.S_IFIFO) ## SftpFile @@ -560,7 +756,7 @@ as their counterparts in `Base.open(::String)`, except for `exclusive` and # Throws - `ArgumentError`: If `sftp` is closed. -- [`LibSSHException`](@ref): If opening the file fails. +- [`SftpException`](@ref): If opening the file fails. """ function Base.open(path::String, sftp::SftpSession; read::Union{Bool, Nothing}=nothing, @@ -600,8 +796,7 @@ function Base.open(path::String, sftp::SftpSession; end if ret == C_NULL - error_code = get_error(sftp) - throw(LibSSHException("Couldn't open file '$path' on host $(sftp.session.host): $error_code")) + throw(SftpException("Couldn't open file", path, sftp)) end file = SftpFile(ret, sftp, path, (; flags..., exclusive)) @@ -684,7 +879,7 @@ Read at most `nb` bytes from the remote [`SftpFile`](@ref). Uses # Throws - `ArgumentError`: If `file` is closed. -- [`LibSSHException`](@ref): If reading failed. +- [`SftpException`](@ref): If reading failed. """ function Base.read(file::SftpFile, nb::Integer=typemax(Int)) if !isopen(file) @@ -710,7 +905,7 @@ parallel requests. # Throws - `ArgumentError`: If `file` is closed. -- [`LibSSHException`](@ref): If reading failed. +- [`SftpException`](@ref): If reading failed. """ function Base.read!(file::SftpFile, out::Vector{UInt8}) if !isopen(file) @@ -728,9 +923,7 @@ function Base.read!(file::SftpFile, out::Vector{UInt8}) handle = Ref{lib.sftp_aio}() ret = lib.sftp_aio_begin_read(file, nb - bytes_requested, handle) if ret == SSH_ERROR - error_code = get_error(file.sftp) - error_msg = get_error(file.sftp.session) - throw(LibSSHException("Read of $file failed with code $error_code: '$error_msg'")) + throw(SftpException("Reading file failed", file)) end push!(handles, (bytes_requested + 1, ret, handle)) @@ -751,7 +944,7 @@ function Base.read!(file::SftpFile, out::Vector{UInt8}) @lockandblock file.sftp.session lib.sftp_aio_wait_read(handle_ptr, buffer_ptr, Csize_t(chunk_size)) end if ret == SSH_ERROR - throw(LibSSHException("Reading $(file) from $(pos):$(pos + chunk_size - 1) failed: $(ret)")) + throw(SftpException("Reading file from $(pos):$(pos + chunk_size - 1)", file)) end end end @@ -777,7 +970,7 @@ uses libssh's asynchronous IO API so it may launch multiple parallel requests. # Throws - `ArgumentError`: If `file` is closed. -- [`LibSSHException`](@ref): If writing fails. +- [`SftpException`](@ref): If writing fails. """ function Base.write(file::SftpFile, data::T) where T <: DenseVector if !isopen(file) @@ -795,9 +988,7 @@ function Base.write(file::SftpFile, data::T) where T <: DenseVector offset = length(data) - bytes_left ret = GC.@preserve data lib.sftp_aio_begin_write(file, Ptr{Cvoid}(pointer(data)) + offset, bytes_left, handle) if ret == SSH_ERROR - error_code = get_error(file.sftp) - error_msg = get_error(file.sftp.session) - throw(LibSSHException("Attempted write to $file failed with code $error_code: '$error_msg'")) + throw(SftpException("Attempted write to file failed", file)) end push!(handles, handle) @@ -817,7 +1008,7 @@ function Base.write(file::SftpFile, data::T) where T <: DenseVector @lockandblock file.sftp.session lib.sftp_aio_wait_write(handle_ptr) end if ret == SSH_ERROR - throw(LibSSHException("Write to $(file) failed: $(ret)")) + throw(SftpException("Write to file failed", file)) end end end @@ -836,3 +1027,44 @@ Write a string directly to `file`. Uses [`Base.write(::SftpFile, ::DenseVector)`](@ref) internally. """ Base.write(file::SftpFile, data::AbstractString) = write(file, codeunits(data)) + +## SftpException + +""" +$(TYPEDEF) +$(TYPEDFIELDS) + +Represents an error from the SFTP subsystem. +""" +struct SftpException <: Exception + msg::String + path::Union{String, Nothing} + error_code::SftpError + session_error::String + session_userhost::String +end + +function SftpException(msg::AbstractString, path, sftp::SftpSession) + userhost = "$(sftp.session.user)@$(sftp.session.host)" + return SftpException(msg, path, get_error(sftp), get_error(sftp.session), userhost) +end + +function SftpException(msg::AbstractString, sftp::SftpSession) + SftpException(msg, nothing, sftp) +end + +function SftpException(msg::AbstractString, file::SftpFile) + SftpException(msg, file.path, file.sftp) +end + +function Base.show(io::IO, ex::SftpException) + print(io, + """ + SftpException: $(ex.msg) ($(ex.error_code)) + Session error: '$(ex.session_error)' + User/host: $(ex.session_userhost) + """) + if !isnothing(ex.path) + print(io, " Remote path: $(ex.path)") + end +end diff --git a/src/utils.jl b/src/utils.jl index e2c3737..781b5e9 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -13,6 +13,8 @@ function _socketpair() return sock1, sock2 end +# This may result in double slashes //, but those are still valid paths +_joinpath_linux(parts...) = join(parts, "/") # Helper type to allow closing a Threads.Condition mutable struct CloseableCondition diff --git a/test/LibSSHTests.jl b/test/LibSSHTests.jl index d940190..d570176 100644 --- a/test/LibSSHTests.jl +++ b/test/LibSSHTests.jl @@ -622,13 +622,28 @@ end end end + @testset "SftpException" begin + demo_server_with_sftp(2222) do sftp + # Just smoke tests to make sure there's no obvious mistakes + ex = ssh.SftpException("foo", sftp) + show(IOBuffer(), ex) + + mktemp() do path, io + open(path, sftp) do file + ex = ssh.SftpException("foo", file) + show(IOBuffer(), ex) + end + end + end + end + @testset "Opening" begin # Test opening demo_server_with_sftp(2222; verbose=false) do sftp mktempdir() do tmpdir # Opening a file that doesn't exist should throw bad_file = joinpath(tmpdir, "no") - @test_throws ssh.LibSSHException open(bad_file, sftp) + @test_throws ssh.SftpException open(bad_file, sftp) # Create a dummy file good_file = joinpath(tmpdir, "foo") @@ -775,9 +790,10 @@ end # extension. @test_throws ErrorException homedir(sftp) + # Test stat() mktemp() do path, io # stat()'ing a non-existent file should fail - @test_throws ssh.LibSSHException stat(path * "_bad", sftp) + @test_throws ssh.SftpException stat(path * "_bad", sftp) attrs = stat(path, sftp) @test isassigned(attrs) @@ -801,6 +817,28 @@ end @test !isassigned(attrs) end + # Test the different `is*()` functions + mktemp() do path, io + @test ispath(path, sftp) + @test isfile(path, sftp) + @test !isdir(path, sftp) + @test !issocket(path, sftp) + @test !islink(path, sftp) + @test !isblockdev(path, sftp) + @test !ischardev(path, sftp) + @test !isfifo(path, sftp) + end + + mktempdir() do tmpdir + @test ispath(tmpdir, sftp) + @test !isfile(tmpdir, sftp) + @test isdir(tmpdir, sftp) + + # They should not throw an exception on non-existent files + @test !isfile(joinpath(tmpdir, "foo"), sftp) + @test !ispath(joinpath(tmpdir, "foo"), sftp) + end + # Test readdir() mktempdir() do tmpdir # Test reading an empty directory @@ -815,7 +853,65 @@ end @test readdir(tmpdir, sftp; only_names=false) isa Vector{ssh.SftpAttributes} # And a non-existent directory - @test_throws ssh.LibSSHException readdir(tmpdir * "_bad", sftp) + @test_throws ssh.SftpException readdir(tmpdir * "_bad", sftp) + end + + # Test rm() + mktempdir() do tmpdir + path = joinpath(tmpdir, "foo") + + # Deleting a non-existent file should fail by default + @test_throws ssh.SftpException rm(path, sftp) + # But not if we pass force=true + rm(path, sftp; force=true) + + # Test deleting a file + write(path, "foo") + rm(path, sftp) + @test !ispath(path) + + # And an empty directory + mkdir(path) + rm(path, sftp) + @test !ispath(path) + + # And a non-empty directory + mkdir(path) + touch(joinpath(path, "foo")) + @test_throws Base.IOError rm(path, sftp) + rm(path, sftp; recursive=true) + @test !ispath(path) + end + + # Test mkdir() + mktempdir() do tmpdir + path = joinpath(tmpdir, "foo") + @test mkdir(path, sftp) == path + @test isdir(path) + + # Creating a directory that already exists should fail + @test_throws ssh.SftpException mkdir(path, sftp) + end + + # Test mv() + mktempdir() do tmpdir + src = joinpath(tmpdir, "foo") + dst = joinpath(tmpdir, "bar") + + # Trying to move a file that doesn't exist should fail + @test_throws ssh.SftpException mv(src, dst, sftp) + + # Sadly the demo server doesn't support sftp_rename() yet + touch(src) + @test_throws ssh.SftpException mv(src, dst, sftp) + @test_broken !isfile(src) + @test_broken isfile(dst) + + # Even though it will fail when doing the rename, it should get + # as far as deleting dst if it already exists. + touch(dst) + @test_throws ssh.SftpException mv(src, dst, sftp; force=true) + @test !ispath(dst) end close(sftp) @@ -825,6 +921,9 @@ end @test_throws ArgumentError ssh.get_limits(sftp) @test_throws ArgumentError homedir(sftp) @test_throws ArgumentError readdir("/tmp", sftp) + @test_throws ArgumentError rm("/tmp", sftp) + @test_throws ArgumentError mkdir("/tmp", sftp) + @test_throws ArgumentError mv("foo", "bar", sftp) end end end @@ -889,8 +988,8 @@ end @test ssh.lib_version() isa VersionNumber end -@testset "Aqua.jl" begin - Aqua.test_all(ssh) -end +# @testset "Aqua.jl" begin +# Aqua.test_all(ssh) +# end end From df3a2eaa61470008a47ab6f9a60acd12587448a3 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 11 Oct 2024 16:47:27 +0200 Subject: [PATCH 2/2] Bump version --- Project.toml | 2 +- docs/src/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 519abdc..8d42b2d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "LibSSH" uuid = "00483490-30f8-4353-8aba-35b82f51f4d0" authors = ["James Wrigley and contributors"] -version = "0.5.0" +version = "0.6.0" [deps] CEnum = "fa961155-64e5-5f13-b03f-caf6b980ea82" diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 20da1c6..6022030 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -7,7 +7,7 @@ CurrentModule = LibSSH This documents notable changes in LibSSH.jl. The format is based on [Keep a Changelog](https://keepachangelog.com). -## Unreleased +## [v0.6.0] - 2024-10-11 ### Added