From 0839ecd433a4d0683998ee3190fec430df056d0e Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Wed, 22 Jul 2026 00:30:54 +0300 Subject: [PATCH 1/3] [cli] Expand remote transfer wildcards Expand quoted instance-to-host wildcard patterns through SFTP directory listings without invoking a remote shell. Reuse the existing multi-source transfer path and require directory targets when a pattern yields multiple files. Add unit, CLI, and documentation coverage for #3885. --- .../command-line-interface/transfer.md | 10 ++ include/multipass/ssh/sftp_client.h | 2 + src/client/cli/cmd/transfer.cpp | 18 ++- src/ssh/sftp_client.cpp | 126 ++++++++++++++++++ tests/cli/cli_transfer_test.py | 20 +++ tests/unit/mock_sftp_client.h | 1 + tests/unit/test_cli_client.cpp | 71 +++++++++- tests/unit/test_sftp_client.cpp | 94 +++++++++++++ 8 files changed, 337 insertions(+), 5 deletions(-) diff --git a/docs/reference/command-line-interface/transfer.md b/docs/reference/command-line-interface/transfer.md index de23c99c37..da2b182c52 100644 --- a/docs/reference/command-line-interface/transfer.md +++ b/docs/reference/command-line-interface/transfer.md @@ -43,6 +43,16 @@ multipass transfer --recursive ample-pigeon:dir . ```{caution}Symbolic links are not followed during recursive transfer.``` +When copying from an instance to the host, source paths can contain `*`, `?`, and character-range +wildcards. Quote the source so that your host shell passes the pattern to Multipass unchanged: + +```{code-block} text +multipass transfer 'ample-pigeon:logs/*.txt' . +``` + +If the pattern matches multiple files, the destination must be a directory. Wildcards in local +source paths are expanded by the host shell as usual. + --- The full `multipass help transfer` output explains the available options: diff --git a/include/multipass/ssh/sftp_client.h b/include/multipass/ssh/sftp_client.h index dfc91964cc..d31bc8dde4 100644 --- a/include/multipass/ssh/sftp_client.h +++ b/include/multipass/ssh/sftp_client.h @@ -24,6 +24,7 @@ #include #include #include +#include #include @@ -54,6 +55,7 @@ class SFTPClient SFTPClient(SSHSessionUPtr ssh_session); virtual bool is_remote_dir(const fs::path& path); + virtual std::vector expand_remote_path(const fs::path& path); virtual bool push(const fs::path& source_path, const fs::path& target_path, Flags flags = {}); virtual bool pull(const fs::path& source_path, const fs::path& target_path, Flags flags = {}); virtual void from_cin(std::istream& cin, const fs::path& target_path, bool make_parent); diff --git a/src/client/cli/cmd/transfer.cpp b/src/client/cli/cmd/transfer.cpp index 91035560b3..614de04e32 100644 --- a/src/client/cli/cmd/transfer.cpp +++ b/src/client/cli/cmd/transfer.cpp @@ -58,15 +58,27 @@ mp::ReturnCodeVariant cmd::Transfer::run(mp::ArgParser* parser) if (const auto args = std::get_if(&arguments); args) { auto& [sources, target] = *args; + std::vector expanded_sources; + for (auto [source, source_end] = sources.equal_range(instance_name); + source != source_end; + ++source) + { + auto matches = sftp_client->expand_remote_path(source->second); + std::move(matches.begin(), + matches.end(), + std::back_inserter(expanded_sources)); + } + std::error_code err; - if (sources.size() > 1 && !MP_FILEOPS.is_directory(target, err) && !err) + if ((sources.size() > 1 || expanded_sources.size() > 1) && + !MP_FILEOPS.is_directory(target, err) && !err) throw std::runtime_error{ fmt::format("Target {:?} is not a directory", target)}; else if (err) throw std::runtime_error{ fmt::format("Cannot access {:?}: {}", target, err.message())}; - for (auto [s, s_end] = sources.equal_range(instance_name); s != s_end; ++s) - success &= sftp_client->pull(s->second, target, flags); + for (const auto& source : expanded_sources) + success &= sftp_client->pull(source, target, flags); } if (const auto args = std::get_if(&arguments); args) diff --git a/src/ssh/sftp_client.cpp b/src/ssh/sftp_client.cpp index 5ecdf2d8f5..1e502cae2b 100644 --- a/src/ssh/sftp_client.cpp +++ b/src/ssh/sftp_client.cpp @@ -27,15 +27,63 @@ #include #include +#include #include #include #include #include +#include +#include + +#include constexpr int file_mode = 0664; const std::string stream_file_name{"stream_output.dat"}; const char* log_category = "sftp"; +namespace +{ +bool has_wildcards(const std::string_view value) +{ + return value.find_first_of("*?[") != std::string_view::npos; +} + +std::vector split_remote_path(const std::string_view path) +{ + std::vector components; + for (std::size_t begin = 0; begin < path.size();) + { + begin = path.find_first_not_of('/', begin); + if (begin == std::string_view::npos) + break; + + const auto end = path.find('/', begin); + components.emplace_back(path.substr(begin, end - begin)); + begin = end == std::string_view::npos ? path.size() : end + 1; + } + return components; +} + +std::string append_remote_component(const std::string_view path, const std::string_view component) +{ + if (path.empty()) + return std::string{component}; + if (path == "/") + return fmt::format("/{}", component); + return fmt::format("{}/{}", path, component); +} + +bool wildcard_matches(const std::string_view pattern, + const std::string_view name, + const QRegularExpression& expression) +{ + if (name.starts_with('.') && !pattern.starts_with('.')) + return false; + + return expression.match(QString::fromStdString(std::string{name})).hasMatch(); +} +} // namespace + namespace multipass { namespace mpl = logging; @@ -76,6 +124,84 @@ bool SFTPClient::is_remote_dir(const fs::path& path) return attr && attr->type == SSH_FILEXFER_TYPE_DIRECTORY; } +std::vector SFTPClient::expand_remote_path(const fs::path& path) +{ + const auto remote_path = path.generic_string(); + if (!has_wildcards(remote_path)) + return {path}; + + auto candidates = std::vector{remote_path.starts_with('/') ? "/" : ""}; + auto expanded_pattern = false; + + for (const auto& component : split_remote_path(remote_path)) + { + if (!has_wildcards(component)) + { + std::transform(candidates.begin(), + candidates.end(), + candidates.begin(), + [&component](const auto& candidate) { + return append_remote_component(candidate, component); + }); + + if (expanded_pattern) + { + std::erase_if(candidates, [this](const auto& candidate) { + return !mp_sftp_stat(sftp.get(), candidate.c_str()); + }); + if (candidates.empty()) + throw SFTPError{"no matches found: {}", path}; + } + continue; + } + + std::vector matches; + const QRegularExpression expression{ + QRegularExpression::wildcardToRegularExpression(QString::fromStdString(component))}; + for (const auto& candidate : candidates) + { + const auto directory_path = candidate.empty() ? "." : candidate; + auto directory = mp_sftp_opendir(sftp.get(), directory_path.c_str()); + if (!directory) + { + if (expanded_pattern) + continue; + throw SFTPError{"cannot open remote directory '{}': {}", + directory_path, + MP_LIBSSH.ssh_get_error(sftp->session)}; + } + + while (auto entry = mp_sftp_readdir(sftp.get(), directory.get())) + { + const std::string_view name{entry->name}; + if (name != "." && name != ".." && wildcard_matches(component, name, expression)) + matches.emplace_back(append_remote_component(candidate, name)); + } + + if (!MP_LIBSSH.sftp_dir_eof(directory.get())) + throw SFTPError{"cannot read remote directory '{}': {}", + directory_path, + MP_LIBSSH.ssh_get_error(sftp->session)}; + } + + if (matches.empty()) + throw SFTPError{"no matches found: {}", path}; + + std::sort(matches.begin(), matches.end()); + matches.erase(std::unique(matches.begin(), matches.end()), matches.end()); + candidates = std::move(matches); + expanded_pattern = true; + } + + std::vector paths; + paths.reserve(candidates.size()); + std::transform(candidates.begin(), + candidates.end(), + std::back_inserter(paths), + [](auto& candidate) { return fs::path{std::move(candidate)}; }); + return paths; +} + bool SFTPClient::push(const fs::path& source_path, const fs::path& target_path, const Flags flags) try { diff --git a/tests/cli/cli_transfer_test.py b/tests/cli/cli_transfer_test.py index 0216bf6bd5..bfd9dfabae 100644 --- a/tests/cli/cli_transfer_test.py +++ b/tests/cli/cli_transfer_test.py @@ -47,6 +47,26 @@ def test_transfer_single_file(self, instance): assert pull_file.exists() assert pull_file.read_text() == "hello from the other side" + def test_transfer_remote_wildcard(self, instance): + """Transfer files matching a wildcard from the guest to the host.""" + with TempDirectory() as tmp: + source = tmp / "wildcard-source" + source.mkdir() + (source / "first.txt").write_text("first") + (source / "second.txt").write_text("second") + (source / "ignored.log").write_text("ignored") + + assert multipass("transfer", "--recursive", str(source), f"{instance}:") + + target = tmp / "wildcard-target" + target.mkdir() + assert multipass( + "transfer", f"{instance}:wildcard-source/*.txt", str(target) + ) + assert (target / "first.txt").read_text() == "first" + assert (target / "second.txt").read_text() == "second" + assert not (target / "ignored.log").exists() + def test_transfer_single_file_create_parents(self, instance): """Transfer a single file from the host to guest where the target is a nested folder structure.""" diff --git a/tests/unit/mock_sftp_client.h b/tests/unit/mock_sftp_client.h index ab1d337685..6769635d0e 100644 --- a/tests/unit/mock_sftp_client.h +++ b/tests/unit/mock_sftp_client.h @@ -25,6 +25,7 @@ namespace multipass::test struct MockSFTPClient : public SFTPClient { MOCK_METHOD(bool, is_remote_dir, (const fs::path& path), (override)); + MOCK_METHOD(std::vector, expand_remote_path, (const fs::path& path), (override)); MOCK_METHOD(bool, push, (const fs::path& source_path, const fs::path& target_path, Flags flags), diff --git a/tests/unit/test_cli_client.cpp b/tests/unit/test_cli_client.cpp index b4389f53e0..4329b14519 100644 --- a/tests/unit/test_cli_client.cpp +++ b/tests/unit/test_cli_client.cpp @@ -643,6 +643,8 @@ TEST_F(Client, transferCmdInstanceSourceLocalTarget) EXPECT_CALL(*mocked_sftp_utils, make_SFTPClient) .WillOnce(Return(std::move(mocked_sftp_client))); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"foo"})) + .WillOnce(Return(std::vector{"foo"})); EXPECT_CALL(*mocked_sftp_client_p, pull).WillOnce(Return(true)); EXPECT_CALL(mock_daemon, ssh_info) .WillOnce([](auto, grpc::ServerReaderWriter* server) { @@ -654,13 +656,72 @@ TEST_F(Client, transferCmdInstanceSourceLocalTarget) EXPECT_EQ(send_command({"transfer", "test-vm:foo", "bar"}), mp::ReturnCode::Ok); } +TEST_F(Client, transferCmdExpandsRemoteWildcard) +{ + auto [mocked_file_ops, mocked_file_ops_guard] = mpt::MockFileOps::inject(); + auto [mocked_sftp_utils, mocked_sftp_utils_guard] = mpt::MockSFTPUtils::inject(); + auto mocked_sftp_client = std::make_unique(); + auto mocked_sftp_client_p = mocked_sftp_client.get(); + + EXPECT_CALL(*mocked_sftp_utils, make_SFTPClient) + .WillOnce(Return(std::move(mocked_sftp_client))); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"dir/*.txt"})) + .WillOnce(Return(std::vector{"dir/first.txt", "dir/second.txt"})); + EXPECT_CALL(*mocked_file_ops, is_directory(fs::path{"target"}, _)).WillOnce(Return(true)); + EXPECT_CALL(*mocked_sftp_client_p, pull(fs::path{"dir/first.txt"}, fs::path{"target"}, _)) + .WillOnce(Return(true)); + EXPECT_CALL(*mocked_sftp_client_p, pull(fs::path{"dir/second.txt"}, fs::path{"target"}, _)) + .WillOnce(Return(true)); + EXPECT_CALL(mock_daemon, ssh_info) + .WillOnce([](auto, grpc::ServerReaderWriter* server) { + mp::SSHInfoReply reply; + reply.mutable_ssh_info()->insert({"test-vm", mp::SSHInfo{}}); + server->Write(reply); + return grpc::Status{}; + }); + + EXPECT_EQ(send_command({"transfer", "test-vm:dir/*.txt", "target"}), mp::ReturnCode::Ok); +} + +TEST_F(Client, transferCmdRemoteWildcardMatchesRequireDirectoryTarget) +{ + auto [mocked_file_ops, mocked_file_ops_guard] = mpt::MockFileOps::inject(); + auto [mocked_sftp_utils, mocked_sftp_utils_guard] = mpt::MockSFTPUtils::inject(); + auto mocked_sftp_client = std::make_unique(); + auto mocked_sftp_client_p = mocked_sftp_client.get(); + + EXPECT_CALL(*mocked_sftp_utils, make_SFTPClient) + .WillOnce(Return(std::move(mocked_sftp_client))); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"dir/*.txt"})) + .WillOnce(Return(std::vector{"dir/first.txt", "dir/second.txt"})); + EXPECT_CALL(*mocked_file_ops, is_directory(fs::path{"target"}, _)).WillOnce(Return(false)); + EXPECT_CALL(mock_daemon, ssh_info) + .WillOnce([](auto, grpc::ServerReaderWriter* server) { + mp::SSHInfoReply reply; + reply.mutable_ssh_info()->insert({"test-vm", mp::SSHInfo{}}); + server->Write(reply); + return grpc::Status{}; + }); + + std::stringstream err; + EXPECT_EQ(send_command({"transfer", "test-vm:dir/*.txt", "target"}, trash_stream, err), + mp::ReturnCode::CommandFail); + EXPECT_THAT(err.str(), HasSubstr("Target \"target\" is not a directory")); +} + TEST_F(Client, transferCmdInstanceSourcesLocalTargetNotDir) { auto [mocked_file_ops, mocked_file_ops_guard] = mpt::MockFileOps::inject(); auto [mocked_sftp_utils, mocked_sftp_utils_guard] = mpt::MockSFTPUtils::inject(); + auto mocked_sftp_client = std::make_unique(); + auto mocked_sftp_client_p = mocked_sftp_client.get(); EXPECT_CALL(*mocked_sftp_utils, make_SFTPClient) - .WillOnce(Return(std::make_unique())); + .WillOnce(Return(std::move(mocked_sftp_client))); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"foo"})) + .WillOnce(Return(std::vector{"foo"})); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"baz"})) + .WillOnce(Return(std::vector{"baz"})); EXPECT_CALL(*mocked_file_ops, is_directory).WillOnce(Return(false)); EXPECT_CALL(mock_daemon, ssh_info) .WillOnce([](auto, grpc::ServerReaderWriter* server) { @@ -680,9 +741,15 @@ TEST_F(Client, transferCmdInstanceSourcesLocalTargetCannotAccess) { auto [mocked_file_ops, mocked_file_ops_guard] = mpt::MockFileOps::inject(); auto [mocked_sftp_utils, mocked_sftp_utils_guard] = mpt::MockSFTPUtils::inject(); + auto mocked_sftp_client = std::make_unique(); + auto mocked_sftp_client_p = mocked_sftp_client.get(); EXPECT_CALL(*mocked_sftp_utils, make_SFTPClient) - .WillOnce(Return(std::make_unique())); + .WillOnce(Return(std::move(mocked_sftp_client))); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"foo"})) + .WillOnce(Return(std::vector{"foo"})); + EXPECT_CALL(*mocked_sftp_client_p, expand_remote_path(fs::path{"baz"})) + .WillOnce(Return(std::vector{"baz"})); auto err = std::make_error_code(std::errc::permission_denied); EXPECT_CALL(*mocked_file_ops, is_directory).WillOnce([&](auto, std::error_code& e) { e = err; diff --git a/tests/unit/test_sftp_client.cpp b/tests/unit/test_sftp_client.cpp index 14b8e4d8e7..44ea39a5b5 100644 --- a/tests/unit/test_sftp_client.cpp +++ b/tests/unit/test_sftp_client.cpp @@ -69,6 +69,13 @@ auto make_unique_dummy_sftp_attr(uint8_t type = SSH_FILEXFER_TYPE_REGULAR, sftp_attributes_free); } +sftp_dir get_dummy_sftp_dir(const fs::path& name) +{ + auto dir = static_cast(calloc(1, sizeof(struct sftp_dir_struct))); + dir->name = strdup(name.string().c_str()); + return dir; +} + struct SFTPClient : public testing::Test { SFTPClient() @@ -156,6 +163,93 @@ TEST_F(SFTPClient, isDir) EXPECT_FALSE(sftp_client.is_remote_dir("not/a/directory")); } +TEST_F(SFTPClient, leavesRemotePathWithoutWildcardsUnchanged) +{ + REPLACE_SFTP_INIT(); + + auto sftp_client = make_sftp_client(); + + EXPECT_THAT(sftp_client.expand_remote_path("dir/file.txt"), + ElementsAre(fs::path{"dir/file.txt"})); +} + +TEST_F(SFTPClient, expandsRemoteWildcard) +{ + REPLACE_SFTP_INIT(); + + std::vector entries{ + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "third.txt"), + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, ".hidden.txt"), + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "second.log"), + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "first.txt"), + nullptr, + }; + REPLACE(sftp_opendir, [](auto, auto path) { return get_dummy_sftp_dir(path); }); + REPLACE(sftp_readdir, [&, index = 0](auto...) mutable { return entries[index++]; }); + REPLACE(sftp_dir_eof, [](auto...) { return true; }); + + auto sftp_client = make_sftp_client(); + + EXPECT_THAT(sftp_client.expand_remote_path("dir/*.txt"), + ElementsAre(fs::path{"dir/first.txt"}, fs::path{"dir/third.txt"})); +} + +TEST_F(SFTPClient, expandsWildcardsAcrossRemotePathComponents) +{ + REPLACE_SFTP_INIT(); + + auto root_index = 0; + auto first_release_index = 0; + auto second_release_index = 0; + std::vector root_entries{ + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_DIRECTORY, "release-b"), + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_DIRECTORY, "release-a"), + nullptr, + }; + std::vector first_release_entries{ + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "first.txt"), + nullptr, + }; + std::vector second_release_entries{ + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "second.txt"), + get_dummy_sftp_attr(SSH_FILEXFER_TYPE_REGULAR, "ignored.log"), + nullptr, + }; + REPLACE(sftp_opendir, [](auto, auto path) { return get_dummy_sftp_dir(path); }); + REPLACE(sftp_readdir, [&](auto, auto directory) -> sftp_attributes { + const std::string_view path{directory->name}; + if (path == ".") + return root_entries[root_index++]; + if (path == "release-a/logs") + return first_release_entries[first_release_index++]; + + return second_release_entries[second_release_index++]; + }); + REPLACE(sftp_stat, + [](auto, auto path) { return get_dummy_sftp_attr(SSH_FILEXFER_TYPE_DIRECTORY, path); }); + REPLACE(sftp_dir_eof, [](auto...) { return true; }); + + auto sftp_client = make_sftp_client(); + + EXPECT_THAT( + sftp_client.expand_remote_path("release-*/logs/*.txt"), + ElementsAre(fs::path{"release-a/logs/first.txt"}, fs::path{"release-b/logs/second.txt"})); +} + +TEST_F(SFTPClient, throwsWhenRemoteWildcardHasNoMatches) +{ + REPLACE_SFTP_INIT(); + REPLACE(sftp_opendir, [](auto, auto path) { return get_dummy_sftp_dir(path); }); + REPLACE(sftp_readdir, [](auto...) { return nullptr; }); + REPLACE(sftp_dir_eof, [](auto...) { return true; }); + + auto sftp_client = make_sftp_client(); + + MP_EXPECT_THROW_THAT(sftp_client.expand_remote_path("dir/*.txt"), + mp::SFTPError, + mpt::match_what(StrEq("no matches found: dir/*.txt"))); +} + TEST_F(SFTPClient, pushFileSuccess) { std::string test_data = "test_data"; From dd90db665ebc129a2bea566a40b55156b1ed46cf Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Wed, 22 Jul 2026 01:06:21 +0300 Subject: [PATCH 2/3] [test] Fix wildcard test compilation --- tests/unit/test_cli_client.cpp | 1 + tests/unit/test_sftp_client.cpp | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_cli_client.cpp b/tests/unit/test_cli_client.cpp index 4329b14519..1d733b6c70 100644 --- a/tests/unit/test_cli_client.cpp +++ b/tests/unit/test_cli_client.cpp @@ -62,6 +62,7 @@ namespace mp = multipass; namespace mcp = multipass::cli::platform; namespace mpt = multipass::test; namespace mpu = multipass::utils; +namespace fs = mp::fs; using namespace testing; namespace diff --git a/tests/unit/test_sftp_client.cpp b/tests/unit/test_sftp_client.cpp index 44ea39a5b5..57b8b3cf02 100644 --- a/tests/unit/test_sftp_client.cpp +++ b/tests/unit/test_sftp_client.cpp @@ -185,7 +185,8 @@ TEST_F(SFTPClient, expandsRemoteWildcard) nullptr, }; REPLACE(sftp_opendir, [](auto, auto path) { return get_dummy_sftp_dir(path); }); - REPLACE(sftp_readdir, [&, index = 0](auto...) mutable { return entries[index++]; }); + auto read_dir = [&, index = 0](auto...) mutable { return entries[index++]; }; + REPLACE(sftp_readdir, read_dir); REPLACE(sftp_dir_eof, [](auto...) { return true; }); auto sftp_client = make_sftp_client(); From 1624a92f8f575c5e4f024ac4f943c67d3e6738e4 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Wed, 22 Jul 2026 02:30:04 +0300 Subject: [PATCH 3/3] [test] Mock SFTP directory cleanup --- tests/unit/c_mock_defines.cmake | 1 + tests/unit/mock_sftp.cpp | 1 + tests/unit/mock_sftp.h | 1 + tests/unit/test_sftp_client.cpp | 11 +++++++++-- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/c_mock_defines.cmake b/tests/unit/c_mock_defines.cmake index b595f198de..0fc5904937 100644 --- a/tests/unit/c_mock_defines.cmake +++ b/tests/unit/c_mock_defines.cmake @@ -79,6 +79,7 @@ add_c_mocks( sftp_stat sftp_lstat sftp_opendir + sftp_closedir sftp_readdir sftp_readlink sftp_mkdir diff --git a/tests/unit/mock_sftp.cpp b/tests/unit/mock_sftp.cpp index ea3a8b6627..154752019e 100644 --- a/tests/unit/mock_sftp.cpp +++ b/tests/unit/mock_sftp.cpp @@ -29,6 +29,7 @@ IMPL_MOCK_DEFAULT(1, sftp_close); IMPL_MOCK_DEFAULT(2, sftp_stat); IMPL_MOCK_DEFAULT(2, sftp_lstat); IMPL_MOCK_DEFAULT(2, sftp_opendir); +IMPL_MOCK_DEFAULT(1, sftp_closedir); IMPL_MOCK_DEFAULT(2, sftp_readdir); IMPL_MOCK_DEFAULT(2, sftp_readlink); IMPL_MOCK_DEFAULT(3, sftp_mkdir); diff --git a/tests/unit/mock_sftp.h b/tests/unit/mock_sftp.h index a2346b12ad..fae1ee6702 100644 --- a/tests/unit/mock_sftp.h +++ b/tests/unit/mock_sftp.h @@ -32,6 +32,7 @@ DECL_MOCK(sftp_close); DECL_MOCK(sftp_stat); DECL_MOCK(sftp_lstat); DECL_MOCK(sftp_opendir); +DECL_MOCK(sftp_closedir); DECL_MOCK(sftp_readdir); DECL_MOCK(sftp_readlink); DECL_MOCK(sftp_mkdir); diff --git a/tests/unit/test_sftp_client.cpp b/tests/unit/test_sftp_client.cpp index 57b8b3cf02..4997d55367 100644 --- a/tests/unit/test_sftp_client.cpp +++ b/tests/unit/test_sftp_client.cpp @@ -86,10 +86,16 @@ struct SFTPClient : public testing::Test return sftp; }}, free_sftp{mock_sftp_free, [](sftp_session sftp) { std::free(sftp); }}, - close_sftp{mock_sftp_close, [](sftp_file file) { + close_sftp{mock_sftp_close, + [](sftp_file file) { std::free(file); return SSH_OK; - }} + }}, + close_sftp_dir{mock_sftp_closedir, [](sftp_dir dir) { + std::free(dir->name); + std::free(dir); + return SSH_OK; + }} { } @@ -108,6 +114,7 @@ struct SFTPClient : public testing::Test MockScope sftp_new; MockScope free_sftp; MockScope close_sftp; + MockScope close_sftp_dir; sftp_limits_struct limits{32768, 32768, 32768, 0};