Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions localization/strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -3133,6 +3133,10 @@ On first run, creates the file with all settings commented out at their defaults
<data name="WSLCCLI_FollowArgDescription" xml:space="preserve">
<value>Follow log output</value>
</data>
<data name="WSLCCLI_FollowLinkArgDescription" xml:space="preserve">
<value>Always follow symlinks in SRC_PATH</value>
<comment>{Locked="SRC_PATH"}Command line arguments should not be translated</comment>
</data>
<data name="WSLCCLI_FormatArgDescription" xml:space="preserve">
<value>Output formatting (json or table) (Default: table)</value>
<comment>{Locked="json"}{Locked="table"}Command line arguments should not be translated</comment>
Expand Down
20 changes: 20 additions & 0 deletions src/windows/inc/docker_schema.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> stream;
Expand Down
4 changes: 4 additions & 0 deletions src/windows/service/inc/wslc.idl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/windows/wslc/arguments/ArgumentDefinitions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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()) \
Expand Down
1 change: 1 addition & 0 deletions src/windows/wslc/commands/ContainerCpCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ std::vector<Argument> 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()}),
};
Expand Down
18 changes: 18 additions & 0 deletions src/windows/wslc/services/ContainerService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> ContainerService::ResolveContainerSymlink(Session& session, const std::string& id, const std::string& srcPath)
{
[[maybe_unused]] auto operation = session.BeginContainerOperation();

wil::com_ptr<IWSLCContainer> 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();
Expand Down
3 changes: 3 additions & 0 deletions src/windows/wslc/services/ContainerService.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> 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);
Expand Down
22 changes: 22 additions & 0 deletions src/windows/wslc/tasks/ContainerTasks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ void ContainerCp(CLIExecutionContext& context)
auto& session = context.Data.Get<Data::Session>();
const auto& source = context.Args.GetValue<ArgType::Source>();
const auto& target = context.Args.GetValue<ArgType::Target>();
const bool followLink = context.Args.GetValue<ArgType::FollowLink>();

// 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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/windows/wslcsession/DockerHTTPClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,36 @@ std::tuple<uint32_t, wil::unique_socket, bool> DockerHTTPClient::GetArchive(cons
return {response.result_int(), std::move(socket), response.chunked()};
}

std::optional<docker_schema::ContainerPathStat> 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<docker_schema::ContainerPathStat>(decoded.c_str());
}

docker_schema::Volume DockerHTTPClient::CreateVolume(const docker_schema::CreateVolume& Request)
{
return Transaction<docker_schema::CreateVolume>(verb::post, URL::Create("/volumes/create"), Request);
Expand Down
4 changes: 4 additions & 0 deletions src/windows/wslcsession/DockerHTTPClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ class DockerHTTPClient
std::pair<uint32_t, wil::unique_socket> ExportContainer(const std::string& ContainerID);
std::unique_ptr<HTTPRequestContext> PutArchive(const std::string& ContainerID, const std::string& Path, std::optional<uint64_t> ContentLength);
std::tuple<uint32_t, wil::unique_socket, bool> 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<common::docker_schema::ContainerPathStat> StatArchivePath(const std::string& ContainerID, const std::string& Path);
common::docker_schema::PruneContainerResult PruneContainers(const std::map<std::string, std::vector<std::string>>& filters = {});

// Volume management.
Expand Down
43 changes: 43 additions & 0 deletions src/windows/wslcsession/WSLCContainer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<wil::unique_cotaskmem_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();
Expand Down Expand Up @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions src/windows/wslcsession/WSLCContainer.h
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class WSLCContainerImpl : public std::enable_shared_from_this<WSLCContainerImpl>
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);
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions test/windows/wslc/CommandLineTestCases.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions test/windows/wslc/WSLCCLICommandUnitTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
40 changes: 40 additions & 0 deletions test/windows/wslc/e2e/WSLCE2EContainerCpTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down