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