diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index a35146ca8..29e5a09e3 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3133,6 +3133,10 @@ On first run, creates the file with all settings commented out at their defaults Follow log output + + Always follow symlinks in SRC_PATH + {Locked="SRC_PATH"}Command line arguments should not be translated + Output formatting (json or table) (Default: table) {Locked="json"}{Locked="table"}Command line arguments should not be translated diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 921d7219a..665312326 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -55,6 +55,26 @@ struct ErrorResponse NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ErrorResponse, message); }; +// Payload of the X-Docker-Container-Path-Stat response header on /containers/{id}/archive. +struct ContainerPathStat +{ + std::string name; + int64_t size{}; + uint32_t mode{}; + std::string mtime; + std::string linkTarget; + + // Go encodes os.FileMode in the mode field; the symlink bit is 27. + static constexpr uint32_t c_modeSymlink = 1u << 27; + + bool IsSymlink() const + { + return (mode & c_modeSymlink) != 0; + } + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPathStat, name, size, mode, mtime, linkTarget); +}; + struct ImageLoadResult { std::optional stream; diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index c4ee8321c..d81fd10ce 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -582,6 +582,10 @@ interface IWSLCContainer : IUnknown HRESULT DisconnectFromNetwork([in] LPCSTR NetworkName); HRESULT UploadArchive([in] WSLCHandle TarHandle, [in, string] LPCSTR DestPath, [in] ULONGLONG ContentSize); HRESULT DownloadArchive([in, string] LPCSTR SrcPath, [in] WSLCHandle OutHandle); + + // Resolves a path inside the container to the target of its symbolic link. Sets *Target to NULL when the path + // does not exist or is not a symbolic link. + HRESULT ResolveArchiveSymlink([in, string] LPCSTR SrcPath, [out] LPSTR* Target); } typedef struct _WSLCDeletedImageInformation diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index b5893c363..5ffe8610f 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -61,6 +61,7 @@ _(EnvFile, "env-file", NO_ALIAS, Kind::Value, _(File, "file", L"f", Kind::Value, NoConversion, Localization::WSLCCLI_FileArgDescription()) \ _(Filter, "filter", L"f", Kind::Value, KeyValuePair, Localization::WSLCCLI_FilterArgDescription()) \ _(Follow, "follow", L"f", Kind::Flag, NoConversion, Localization::WSLCCLI_FollowArgDescription()) \ +_(FollowLink, "follow-link", L"L", Kind::Flag, NoConversion, Localization::WSLCCLI_FollowLinkArgDescription()) \ _(Timestamps, "timestamps", L"t", Kind::Flag, NoConversion, Localization::WSLCCLI_TimestampsArgDescription()) \ _(Since, "since", NO_ALIAS, Kind::Value, LONGLONG, Localization::WSLCCLI_SinceArgDescription()) \ _(Until, "until", NO_ALIAS, Kind::Value, LONGLONG, Localization::WSLCCLI_UntilArgDescription()) \ diff --git a/src/windows/wslc/commands/ContainerCpCommand.cpp b/src/windows/wslc/commands/ContainerCpCommand.cpp index 849e31a45..9f5712035 100644 --- a/src/windows/wslc/commands/ContainerCpCommand.cpp +++ b/src/windows/wslc/commands/ContainerCpCommand.cpp @@ -16,6 +16,7 @@ std::vector ContainerCpCommand::GetArguments() const { return { Argument::Create(ArgType::Archive), + Argument::Create(ArgType::FollowLink), Argument::Create(ArgType::Source, {.Required = true, .Desc = Localization::WSLCCLI_CpSourceArgDescription()}), Argument::Create(ArgType::Target, {.Required = true, .Desc = Localization::WSLCCLI_CpTargetArgDescription()}), }; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index a8639ca99..999bf39e2 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -828,6 +828,24 @@ void ContainerService::CopyFromContainer(Session& session, const std::string& id THROW_IF_FAILED(container->DownloadArchive(srcPath.c_str(), ToCOMInputHandle(outputHandle))); } +std::optional ContainerService::ResolveContainerSymlink(Session& session, const std::string& id, const std::string& srcPath) +{ + [[maybe_unused]] auto operation = session.BeginContainerOperation(); + + wil::com_ptr container; + THROW_IF_FAILED(session.Get()->OpenContainer(id.c_str(), &container)); + + wil::unique_cotaskmem_ansistring target; + THROW_IF_FAILED(container->ResolveArchiveSymlink(srcPath.c_str(), &target)); + + if (!target) + { + return std::nullopt; + } + + return std::string(target.get()); +} + void ContainerService::Logs(Session& session, const std::string& id, bool follow, bool timestamps, LONGLONG since, LONGLONG until, ULONGLONG tail) { [[maybe_unused]] auto operation = session.BeginContainerOperation(); diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 29b009e67..348737de5 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -64,6 +64,9 @@ struct ContainerService static void Export(models::Session& session, const std::string& id, HANDLE outputHandle); static void CopyToContainer(models::Session& session, const std::string& id, const std::string& destPath, HANDLE inputHandle, ULONGLONG contentSize); static void CopyFromContainer(models::Session& session, const std::string& id, const std::string& srcPath, HANDLE outputHandle); + + // Returns the target of a symbolic link inside the container, or nullopt if the path is not a symbolic link. + static std::optional ResolveContainerSymlink(models::Session& session, const std::string& id, const std::string& srcPath); static wsl::windows::common::wslc_schema::InspectContainer Inspect(models::Session& session, const std::string& id); static void Logs(models::Session& session, const std::string& id, bool follow, bool timestamps, LONGLONG since, LONGLONG until, ULONGLONG tail = 0); static wsl::windows::common::docker_schema::ContainerStats Stats(models::Session& session, const std::string& id); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index edc48b934..6f00fa2de 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -303,6 +303,7 @@ void ContainerCp(CLIExecutionContext& context) auto& session = context.Data.Get(); const auto& source = context.Args.GetValue(); const auto& target = context.Args.GetValue(); + const bool followLink = context.Args.GetValue(); // Determine copy direction by looking for CONTAINER:PATH patterns. // A single letter before ':' is a Windows drive path (e.g. C:\path), not a container reference. @@ -371,6 +372,17 @@ void ContainerCp(CLIExecutionContext& context) THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpSourceNotFoundError(source), fsError || !pathExists); auto absPath = std::filesystem::absolute(source); + + // With --follow-link, resolve symlinks in the source so the link target is archived instead of the + // link itself. + if (followLink) + { + std::error_code linkError; + auto resolved = std::filesystem::canonical(absPath, linkError); + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpSourceNotFoundError(source), !!linkError); + absPath = std::move(resolved); + } + auto parentDir = absPath.parent_path().wstring(); auto fileName = absPath.filename().wstring(); @@ -408,6 +420,16 @@ void ContainerCp(CLIExecutionContext& context) auto [containerId, srcPath] = parseContainerPath(source); THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_CpInvalidSourceError(), containerId.empty() || srcPath.empty()); + // With --follow-link, when the container path is a symbolic link, copy what it points at. + if (followLink) + { + auto linkTarget = ContainerService::ResolveContainerSymlink(session, containerId, srcPath); + if (linkTarget.has_value()) + { + srcPath = std::move(*linkTarget); + } + } + // Resolve any symlinks in the target path since tar.exe refuses to extract through a symlink. std::error_code canonicalError; auto absTarget = wsl::windows::common::filesystem::GetCanonicalPath(target, canonicalError); diff --git a/src/windows/wslcsession/DockerHTTPClient.cpp b/src/windows/wslcsession/DockerHTTPClient.cpp index ae04b4511..0645d57ef 100644 --- a/src/windows/wslcsession/DockerHTTPClient.cpp +++ b/src/windows/wslcsession/DockerHTTPClient.cpp @@ -433,6 +433,36 @@ std::tuple DockerHTTPClient::GetArchive(cons return {response.result_int(), std::move(socket), response.chunked()}; } +std::optional DockerHTTPClient::StatArchivePath(const std::string& ContainerID, const std::string& Path) +{ + auto url = URL::Create("/containers/{}/archive", ContainerID); + url.SetParameter("path", Path); + + // The engine reports the stat in a response header, so the archive body is never read and the connection is + // dropped as soon as the header has been parsed. + auto [response, socket] = SendRequest(verb::get, url, {}, {}); + socket.reset(); + + if (response.result_int() == 404) + { + return std::nullopt; + } + + if (response.result_int() != 200) + { + throw DockerHTTPException(std::move(response), verb::get, url.Get(), "", ""); + } + + const auto header = response["X-Docker-Container-Path-Stat"]; + if (header.empty()) + { + return std::nullopt; + } + + const auto decoded = wslutil::Base64Decode(std::string(header)); + return wsl::shared::FromJson(decoded.c_str()); +} + docker_schema::Volume DockerHTTPClient::CreateVolume(const docker_schema::CreateVolume& Request) { return Transaction(verb::post, URL::Create("/volumes/create"), Request); diff --git a/src/windows/wslcsession/DockerHTTPClient.h b/src/windows/wslcsession/DockerHTTPClient.h index 2f8f23253..6f3e49887 100644 --- a/src/windows/wslcsession/DockerHTTPClient.h +++ b/src/windows/wslcsession/DockerHTTPClient.h @@ -145,6 +145,10 @@ class DockerHTTPClient std::pair ExportContainer(const std::string& ContainerID); std::unique_ptr PutArchive(const std::string& ContainerID, const std::string& Path, std::optional ContentLength); std::tuple GetArchive(const std::string& ContainerID, const std::string& Path); + + // Reads the X-Docker-Container-Path-Stat header for a container path. Returns nullopt if the path does not + // exist or the response carries no stat header. Any other failure status throws. + std::optional StatArchivePath(const std::string& ContainerID, const std::string& Path); common::docker_schema::PruneContainerResult PruneContainers(const std::map>& filters = {}); // Volume management. diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index 495e0c152..02642b9bd 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -2679,6 +2679,33 @@ std::string WSLCContainerImpl::InspectLockHeld() const return wsl::shared::ToJson(wslcInspect); } +void WSLCContainerImpl::ResolveArchiveSymlink(LPCSTR SrcPath, LPSTR* Target) const +{ + auto lock = m_lock.lock_shared(); + + *Target = nullptr; + + const auto stat = m_runtime.Docker().StatArchivePath(m_id, SrcPath); + if (!stat.has_value() || !stat->IsSymlink() || stat->linkTarget.empty()) + { + return; + } + + // Container paths are POSIX. A relative link target is resolved against the directory holding the link. + std::string resolved = stat->linkTarget; + if (resolved.front() != '/') + { + const std::string source(SrcPath); + const auto separator = source.find_last_of('/'); + if (separator != std::string::npos) + { + resolved = source.substr(0, separator + 1) + resolved; + } + } + + *Target = wil::make_unique_ansistring(resolved.c_str()).release(); +} + void WSLCContainerImpl::Logs(WSLCLogsFlags Flags, WSLCHandle* Stdout, WSLCHandle* Stderr, LONGLONG Since, LONGLONG Until, ULONGLONG Tail) const { auto lock = m_lock.lock_shared(); @@ -3184,6 +3211,22 @@ try } CATCH_RETURN(); +HRESULT WSLCContainer::ResolveArchiveSymlink(LPCSTR SrcPath, LPSTR* Target) +try +{ + WSLCExecutionContext context(&m_session); + + RETURN_HR_IF(E_POINTER, SrcPath == nullptr); + RETURN_HR_IF(E_POINTER, Target == nullptr); + RETURN_HR_IF(E_INVALIDARG, SrcPath[0] == '\0'); + + *Target = nullptr; + + auto vmLease = m_session.Runtime().AcquireVmLease(); + return CallImpl(&WSLCContainerImpl::ResolveArchiveSymlink, SrcPath, Target); +} +CATCH_RETURN(); + HRESULT WSLCContainer::Logs(WSLCLogsFlags Flags, WSLCHandle* Stdout, WSLCHandle* Stderr, LONGLONG Since, LONGLONG Until, ULONGLONG Tail) try { diff --git a/src/windows/wslcsession/WSLCContainer.h b/src/windows/wslcsession/WSLCContainer.h index f145aaaee..b4a3ba78e 100644 --- a/src/windows/wslcsession/WSLCContainer.h +++ b/src/windows/wslcsession/WSLCContainer.h @@ -101,6 +101,7 @@ class WSLCContainerImpl : public std::enable_shared_from_this void Export(WSLCHandle TarHandle) const; void UploadArchive(WSLCHandle TarHandle, LPCSTR DestPath, ULONGLONG ContentSize) const; void DownloadArchive(LPCSTR SrcPath, WSLCHandle OutHandle) const; + void ResolveArchiveSymlink(LPCSTR SrcPath, LPSTR* Target) const; void GetStateChangedAt(_Out_ LONGLONG* StateChangedAt); void GetCreatedAt(_Out_ LONGLONG* CreatedAt); void GetState(_Out_ WSLCContainerState* State); @@ -275,6 +276,7 @@ class DECLSPEC_UUID("B1F1C4E3-C225-4CAE-AD8A-34C004DE1AE4") WSLCContainer IFACEMETHOD(Export)(_In_ WSLCHandle TarHandle) override; IFACEMETHOD(UploadArchive)(_In_ WSLCHandle TarHandle, _In_ LPCSTR DestPath, _In_ ULONGLONG ContentSize) override; IFACEMETHOD(DownloadArchive)(_In_ LPCSTR SrcPath, _In_ WSLCHandle OutHandle) override; + IFACEMETHOD(ResolveArchiveSymlink)(_In_ LPCSTR SrcPath, _Out_ LPSTR* Target) override; IFACEMETHOD(GetState)(_Out_ WSLCContainerState* State) override; IFACEMETHOD(GetInitProcess)(_Out_ IWSLCProcess** process) override; IFACEMETHOD(Exec)(_In_ const WSLCProcessOptions* Options, _In_opt_ const WSLCProcessStartOptions* StartOptions, _Out_ IWSLCProcess** Process) override; diff --git a/test/windows/wslc/CommandLineTestCases.h b/test/windows/wslc/CommandLineTestCases.h index 849a5353b..3c45ee006 100644 --- a/test/windows/wslc/CommandLineTestCases.h +++ b/test/windows/wslc/CommandLineTestCases.h @@ -251,6 +251,17 @@ COMMAND_LINE_TEST_CASE(L"container cp -a=1 - cont1:/path", L"cp", true) COMMAND_LINE_TEST_CASE(L"container cp -a=0 - cont1:/path", L"cp", true) COMMAND_LINE_TEST_CASE(L"container cp -a=invalid - cont1:/path", L"cp", false) COMMAND_LINE_TEST_CASE(L"container cp --archive=invalid - cont1:/path", L"cp", false) +COMMAND_LINE_TEST_CASE(L"container cp -L cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp --follow-link cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp --follow-link somefile cont1:/path", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp -L=true cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp -L=false cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp --follow-link=true cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp --follow-link=false cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp -a -L cont1:/path somefile", L"cp", true) +COMMAND_LINE_TEST_CASE(L"container cp -L=invalid cont1:/path somefile", L"cp", false) +COMMAND_LINE_TEST_CASE(L"container cp --followlink cont1:/path somefile", L"cp", false) +COMMAND_LINE_TEST_CASE(L"container cp -l cont1:/path somefile", L"cp", false) COMMAND_LINE_TEST_CASE(L"container cp", L"cp", false) COMMAND_LINE_TEST_CASE(L"container cp -", L"cp", false) COMMAND_LINE_TEST_CASE(L"container cp - ", L"cp", false) diff --git a/test/windows/wslc/WSLCCLICommandUnitTests.cpp b/test/windows/wslc/WSLCCLICommandUnitTests.cpp index f54c9b722..499a24af3 100644 --- a/test/windows/wslc/WSLCCLICommandUnitTests.cpp +++ b/test/windows/wslc/WSLCCLICommandUnitTests.cpp @@ -183,6 +183,28 @@ class WSLCCLICommandUnitTests VERIFY_ARE_EQUAL(2u, cmd.GetAllArguments().size()); } + // --follow-link is exposed with the -L short alias. + TEST_METHOD(ContainerCpCommand_HasFollowLinkArgumentWithAlias) + { + auto cmd = ContainerCpCommand(L"wslc"); + + bool found = false; + for (const auto& arg : cmd.GetArguments()) + { + if (arg.Type() == ArgType::FollowLink) + { + found = true; + VERIFY_ARE_EQUAL(std::wstring(L"follow-link"), arg.Name()); + VERIFY_ARE_EQUAL(std::wstring(L"L"), arg.Alias()); + VERIFY_ARE_EQUAL(Kind::Flag, arg.Kind()); + VERIFY_IS_FALSE(arg.Required()); + break; + } + } + + VERIFY_IS_TRUE(found, L"ContainerCpCommand should expose --follow-link"); + } + // Test: Verify RootCommand contains VersionCommand as a subcommand TEST_METHOD(RootCommand_ContainsVersionCommand) { diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCpTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCpTests.cpp index 6509f2255..7c0eac9dd 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCpTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCpTests.cpp @@ -53,6 +53,16 @@ class WSLCE2EContainerCpTests VERIFY_ARE_EQUAL(L"", result.Stderr.value()); } + WSLC_TEST_METHOD(WSLCE2E_Container_Cp_HelpListsFollowLink) + { + auto result = RunWslc(L"container cp --help"); + VERIFY_IS_TRUE(result.ExitCode.has_value()); + VERIFY_ARE_EQUAL(0u, result.ExitCode.value()); + VERIFY_IS_TRUE(result.Stdout.has_value()); + VERIFY_IS_TRUE(result.Stdout->find(L"--follow-link") != std::wstring::npos); + VERIFY_IS_TRUE(result.Stdout->find(L"-L") != std::wstring::npos); + } + WSLC_TEST_METHOD(WSLCE2E_Container_Cp_MissingBothArgs) { const auto result = RunWslc(L"container cp"); @@ -484,6 +494,36 @@ class WSLCE2EContainerCpTests VERIFY_IS_TRUE(std::filesystem::is_regular_file(targetFile)); } + WSLC_TEST_METHOD(WSLCE2E_Container_Cp_ContainerToLocal_FollowLinkCopiesTarget) + { + auto runResult = + RunWslc(std::format(L"container run -d --name {} {} sleep infinity", WslcContainerName, DebianImage.NameAndTag())); + runResult.Verify({.Stderr = L"", .ExitCode = 0}); + + // A regular file plus a symbolic link pointing at it. + auto execResult = RunWslc( + std::format(L"container exec {} sh -c \"echo follow-link-target > /tmp/linktarget.txt; ln -s /tmp/linktarget.txt /tmp/thelink.txt\"", WslcContainerName)); + execResult.Verify({.ExitCode = 0}); + + auto downloadDir = std::filesystem::current_path() / L"wslc-cp-follow-link-test"; + std::filesystem::create_directories(downloadDir); + auto cleanupDir = wil::scope_exit([&] { std::filesystem::remove_all(downloadDir); }); + + // --follow-link copies what the link points at, so the target's name and contents land locally. + const auto cpResult = + RunWslc(std::format(L"container cp --follow-link {}:/tmp/thelink.txt {}", WslcContainerName, downloadDir.wstring())); + cpResult.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0}); + + const auto copied = downloadDir / L"linktarget.txt"; + VERIFY_IS_TRUE(std::filesystem::exists(copied)); + VERIFY_IS_TRUE(std::filesystem::is_regular_file(copied)); + VERIFY_IS_FALSE(std::filesystem::is_symlink(copied)); + VERIFY_ARE_EQUAL(std::wstring(L"follow-link-target\n"), ReadFileContent(copied.wstring())); + + // The link's own name is never used as the destination when the link is followed. + VERIFY_IS_FALSE(std::filesystem::exists(downloadDir / L"thelink.txt")); + } + WSLC_TEST_METHOD(WSLCE2E_Container_Cp_ContainerToLocal_NonexistentPath) { // Regression test: DownloadArchive used to hang on 404 because the HTTP/1.1 keep-alive