Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1ab8ea2
Simplify e2e build-test image cleanup with per-test RAII ownership
johnstep Jul 14, 2026
a85aaeb
Add wslc image build --secret switch
johnstep Jul 14, 2026
2d9c24d
Error on bare --secret id with unset env var
ggarzia-MSFT Jul 17, 2026
f7cbe09
Reject empty secret id and track mounts before mounting
ggarzia-MSFT Jul 17, 2026
f17fdbb
Reject secret id starting with '-' client-side
ggarzia-MSFT Jul 17, 2026
6c5e119
Move --secret input validation into ValidateSecretSpec
ggarzia-MSFT Jul 17, 2026
af5dd73
Merge branch 'master' into user/johnstep/build-secret
ggarzia-MSFT Jul 17, 2026
9b34bc0
fix resource comment
ggarzia-MSFT Jul 17, 2026
0f77d44
Merge branch 'user/johnstep/build-secret' of https://github.com/micro…
ggarzia-MSFT Jul 17, 2026
c08a908
fix resource comment
ggarzia-MSFT Jul 20, 2026
1a7e353
Avoid null pointer arithmetic for empty build secret
ggarzia-MSFT Jul 20, 2026
f982ba0
Record VM mount only after it succeeds
ggarzia-MSFT Jul 20, 2026
0075638
Validate shared secret build context is a directory
ggarzia-MSFT Jul 20, 2026
58beb90
Merge branch 'master' into user/johnstep/build-secret
ggarzia-MSFT Jul 20, 2026
d0c41db
Pass Reporter to BuildImageCallback and TryInspectImage
ggarzia-MSFT Jul 20, 2026
143c246
Apply clang-format to secret build test lines
ggarzia-MSFT Jul 20, 2026
ebca58b
Revert "Apply clang-format to secret build test lines"
ggarzia-MSFT Jul 20, 2026
b0931e2
Format secret build test with pipeline clang-format
ggarzia-MSFT Jul 20, 2026
0a32bc0
Format ValidateSecretSpec with pipeline clang-format
ggarzia-MSFT Jul 20, 2026
8bf6243
Verify secret build success by exit code, not empty stderr
ggarzia-MSFT Jul 20, 2026
df88fb5
Read file secret by size to detect truncated reads
ggarzia-MSFT Jul 20, 2026
eb7726d
Pass nullptr for empty build secret value
ggarzia-MSFT Jul 20, 2026
f2b7756
Log build secret cleanup failures
ggarzia-MSFT Jul 20, 2026
81ec735
Merge branch 'master' into user/johnstep/build-secret
ggarzia-MSFT Jul 20, 2026
6a819c7
Merge secret spec validation and parsing into ParseSecretSpec
ggarzia-MSFT Jul 20, 2026
368cc13
Merge branch 'master' into user/johnstep/build-secret
ggarzia-MSFT Jul 21, 2026
3086946
Add secret parser unit tests and share env-read helper
ggarzia-MSFT Jul 21, 2026
6a7131c
Extract spec parsers into SpecParsing
ggarzia-MSFT Jul 21, 2026
fbdbd4b
Use ScopedEnvVariable in tests
ggarzia-MSFT Jul 21, 2026
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
8 changes: 8 additions & 0 deletions localization/strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2327,6 +2327,10 @@ For privacy information about this product please visit https://aka.ms/privacy.<
<value>Image '{}' not found.</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="MessageWslcSecretInvalidSpec" xml:space="preserve">
<value>Invalid --secret value '{}': {}</value>
<comment>{FixedPlaceholder="{}"}Command line arguments and string inserts should not be translated</comment>
</data>
<data name = "MessageWslcMissingVolumeOption" xml:space = "preserve" >
<value>Missing required option: '{}'</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
Expand Down Expand Up @@ -2868,6 +2872,10 @@ On first run, creates the file with all settings commented out at their defaults
<data name="WSLCCLI_BuildTargetArgDescription" xml:space="preserve">
<value>Set the target build stage to build</value>
</data>
<data name="WSLCCLI_SecretArgDescription" xml:space="preserve">
<value>Expose secret to the build (id=NAME[,type=env|file][,env=VAR|,src=PATH]; defaults to env var NAME)</value>
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Comment thread
ggarzia-MSFT marked this conversation as resolved.
<comment>{Locked="id=NAME"}{Locked="type=env|file"}{Locked="env=VAR"}{Locked="src=PATH"}Command line arguments should not be translated</comment>
</data>
Comment thread
ggarzia-MSFT marked this conversation as resolved.
<data name="WSLCCLI_CommandArgDescription" xml:space="preserve">
<value>The command to run</value>
</data>
Expand Down
17 changes: 17 additions & 0 deletions src/windows/service/inc/wslc.idl
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,22 @@ typedef struct _WSLCStringArray
ULONG Count;
} WSLCStringArray;

typedef struct _WSLCBuildSecret
{
[string] LPCSTR Id; // Value for docker's --secret id= field.
// Raw secret bytes (never cross into argv). Carried as a counted byte array so arbitrary binary
// content - including embedded NULs - round-trips losslessly; the server materializes it into a
// root-only tmpfs file inside the VM and references it with docker's --secret src=.
[unique, size_is(ValueSize)] const byte* Value;
ULONG ValueSize;
} WSLCBuildSecret;

typedef struct _WSLCBuildSecretArray
{
[unique, size_is(Count)] const WSLCBuildSecret* Values;
ULONG Count;
} WSLCBuildSecretArray;

typedef struct _WSLCProcessOptions
{
[unique] LPCSTR CurrentDirectory;
Expand Down Expand Up @@ -524,6 +540,7 @@ typedef struct _WSLCBuildImageOptions
LPCSTR Target; // Target build stage name passed as --target to docker.
WSLCBuildImageFlags Flags; // WSLCBuildImageFlags
WSLCStringArray Labels; // KEY=VALUE pairs passed as --label to docker.
WSLCBuildSecretArray Secrets; // --secret entries; the server writes each secret's bytes to a root-only tmpfs file in the VM and emits the corresponding id=...,src=... spec.
} WSLCBuildImageOptions;

typedef struct _WSLCTagImageOptions
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 @@ -105,6 +105,7 @@ _(PublishAll, "publish-all", L"P", Kind::Flag, L
_(Quiet, "quiet", L"q", Kind::Flag, Localization::WSLCCLI_QuietArgDescription()) \
_(Remove, "rm", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_RemoveArgDescription()) \
/*_(Scheme, "scheme", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SchemeArgDescription())*/ \
_(Secret, "secret", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SecretArgDescription()) \
_(Server, "server", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_LoginServerArgDescription()) \
_(Session, "session", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SessionIdArgDescription()) \
_(ShmSize, "shm-size", NO_ALIAS, Kind::Value, Localization::WSLCCLI_ShmSizeArgDescription()) \
Expand Down
1 change: 1 addition & 0 deletions src/windows/wslc/commands/ImageBuildCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ std::vector<Argument> ImageBuildCommand::GetArguments() const
Argument::Create(ArgType::File),
Argument::Create(ArgType::Label, false, NO_LIMIT),
Argument::Create(ArgType::NoCache),
Argument::Create(ArgType::Secret, false, NO_LIMIT),
Argument::Create(ArgType::Tag, false, NO_LIMIT),
Argument::Create(ArgType::Verbose),
};
Expand Down
19 changes: 19 additions & 0 deletions src/windows/wslc/services/ImageService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ void ImageService::Build(
const std::vector<std::wstring>& tags,
const std::vector<std::wstring>& buildArgs,
const std::vector<std::wstring>& labels,
const std::vector<BuildSecret>& secrets,
const std::wstring& dockerfilePath,
const std::wstring& target,
WSLCBuildImageFlags flags,
Expand Down Expand Up @@ -172,6 +173,23 @@ void ImageService::Build(
std::vector<LPCSTR> labelPointers;
toMultiByte(labels, labelStrings, labelPointers);

// Keep narrow-encoded id strings alive for the duration of the COM call. The raw secret bytes are
// referenced in place from the caller's BuildSecret objects (which outlive this call), so they are
// never copied or NUL-truncated.
Comment thread
ggarzia-MSFT marked this conversation as resolved.
std::vector<std::string> secretIdStrings;
std::vector<WSLCBuildSecret> secretEntries;
secretIdStrings.reserve(secrets.size());
secretEntries.reserve(secrets.size());
for (const auto& secret : secrets)
{
secretIdStrings.push_back(wsl::windows::common::string::WideToMultiByte(secret.Id));
secretEntries.push_back(WSLCBuildSecret{
.Id = secretIdStrings.back().c_str(),
.Value = secret.Value.data(),
.ValueSize = static_cast<ULONG>(secret.Value.size()),
});
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Comment thread
ggarzia-MSFT marked this conversation as resolved.
}
Comment thread
ggarzia-MSFT marked this conversation as resolved.

auto targetStr = wsl::windows::common::string::WideToMultiByte(target);

auto contextPathStr = absolutePath.wstring();
Expand All @@ -183,6 +201,7 @@ void ImageService::Build(
.Target = targetStr.empty() ? nullptr : targetStr.c_str(),
.Flags = flags,
.Labels = {labelPointers.data(), static_cast<ULONG>(labelPointers.size())},
.Secrets = {secretEntries.data(), static_cast<ULONG>(secretEntries.size())},
};

THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent));
Expand Down
8 changes: 8 additions & 0 deletions src/windows/wslc/services/ImageService.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ Module Name:
#include <wslc_schema.h>

namespace wsl::windows::wslc::services {

struct BuildSecret
{
std::wstring Id; // value for docker's --secret id= field
std::vector<BYTE> Value; // raw secret bytes (may contain NULs); materialized into a VM tmpfs file server-side
};

class ImageService
{
public:
Expand All @@ -27,6 +34,7 @@ class ImageService
const std::vector<std::wstring>& tags,
const std::vector<std::wstring>& buildArgs,
const std::vector<std::wstring>& labels,
const std::vector<BuildSecret>& secrets,
const std::wstring& dockerfilePath,
const std::wstring& target,
WSLCBuildImageFlags flags,
Expand Down
165 changes: 164 additions & 1 deletion src/windows/wslc/tasks/ImageTasks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Module Name:
#include "TableOutput.h"
#include "Task.h"
#include <format>
#include <fstream>
#include <wslutil.h>

using namespace wsl::shared;
Expand Down Expand Up @@ -71,6 +72,159 @@ namespace {

} // namespace

static services::BuildSecret ParseSecretSpec(const std::wstring& spec)
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Outdated
{
std::wstring id;
std::wstring type;
std::wstring envName;
std::wstring srcPath;

for (const auto& part : wsl::shared::string::Split(spec, L','))
{
auto eq = part.find(L'=');
if (eq == std::wstring::npos || eq == 0)
{
THROW_HR_WITH_USER_ERROR(
E_INVALIDARG, Localization::MessageWslcSecretInvalidSpec(spec, L"expected key=value pairs separated by ','"));
}
auto key = part.substr(0, eq);
auto value = part.substr(eq + 1);

if (key == L"id")
{
id = value;
}
else if (key == L"type")
{
type = value;
}
else if (key == L"env")
{
envName = value;
}
else if (key == L"src" || key == L"source")
{
srcPath = value;
}
else
{
THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported key '{}'", key)));
}
}

if (id.empty())
{
THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::MessageWslcSecretInvalidSpec(spec, L"'id=' is required"));
}

// The id is forwarded into docker's comma/'='-delimited --secret spec, so reject any character
// that could break out of the id= field and inject additional options (e.g. ",src=/etc/passwd").
for (auto ch : id)
{
const bool allowed = (ch >= L'a' && ch <= L'z') || (ch >= L'A' && ch <= L'Z') || (ch >= L'0' && ch <= L'9') ||
ch == L'_' || ch == L'-' || ch == L'.';
if (!allowed)
{
THROW_HR_WITH_USER_ERROR(
E_INVALIDARG,
Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may only contain letters, digits, '_', '-' or '.'"));
}
}
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Outdated

if (!type.empty() && type != L"file" && type != L"env")
{
THROW_HR_WITH_USER_ERROR(
E_INVALIDARG, Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported secret type '{}'", type)));
}

// Docker parity: 'type=file' names a source file, so it requires 'src='. Without it we would
// otherwise fall through to reading an environment variable, silently contradicting the type.
if (type == L"file" && srcPath.empty())
{
THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::MessageWslcSecretInvalidSpec(spec, L"'type=file' requires 'src='"));
}

// Docker parity: with 'type=env', a bare 'src=' names the environment variable to read (rather
// than a file path), unless an explicit 'env=' was also given.
if (type == L"env" && envName.empty() && !srcPath.empty())
{
envName = std::move(srcPath);
srcPath.clear();
}

if (!envName.empty() && !srcPath.empty())
{
// Docker parity: 'env=' and 'src=' are not mutually exclusive; when both are given the
// environment variable wins and the file path is ignored.
srcPath.clear();
}
if (envName.empty() && srcPath.empty())
{
// Docker parity: with neither 'env=' nor 'src=', the secret value is read from the host
// environment variable whose name matches the id. Unlike an explicit 'env=', that variable
// must be set - Docker errors when the id-named variable is undefined.
envName = id;
if (GetEnvironmentVariableW(envName.c_str(), nullptr, 0) == 0 && GetLastError() == ERROR_ENVVAR_NOT_FOUND)
{
THROW_HR_WITH_USER_ERROR(
E_INVALIDARG,
Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"environment variable '{}' is not set", envName)));
}
}

if (!srcPath.empty())
{
std::error_code ec;
// Resolve symlinks (and normalize '..') so we read the file that actually holds the secret's
// bytes rather than the link node itself.
auto absPath = std::filesystem::weakly_canonical(std::filesystem::absolute(srcPath), ec);
if (ec.value() != 0 || !std::filesystem::is_regular_file(absPath, ec))
{
THROW_HR_WITH_USER_ERROR(
E_INVALIDARG,
Localization::MessageWslcSecretInvalidSpec(
spec, std::format(L"source file not found or not a regular file: {}", absPath.wstring())));
}

// Read the file's raw bytes and forward them verbatim. The server materializes them into a
// root-only tmpfs file inside the VM, so file secrets are byte-exact (binary, embedded NULs,
// and arbitrary size all round-trip) - matching Docker's type=file semantics - without ever
// mounting a host directory into the VM.
std::ifstream file(absPath, std::ios::binary);
THROW_HR_WITH_USER_ERROR_IF(
E_INVALIDARG,
Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unable to open source file: {}", absPath.wstring())),
!file);
return services::BuildSecret{
.Id = std::move(id),
.Value = std::vector<BYTE>(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()),
};
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Outdated
}

// Docker parity: a referenced environment variable that is unset (or set but empty) yields an
// empty secret value rather than an error. GetEnvironmentVariableW returns 0 for an undefined
// variable; for a defined one it returns the buffer size needed including the null terminator.
std::wstring value;
DWORD size = GetEnvironmentVariableW(envName.c_str(), nullptr, 0);
if (size > 0)
{
value.resize(size);
DWORD written = GetEnvironmentVariableW(envName.c_str(), value.data(), size);
// If the variable grew between the size query above and this read, GetEnvironmentVariableW
// returns the newly-required size (>= our buffer) without filling it; treat that as an error
// rather than forwarding a truncated/garbage secret value to the build.
THROW_HR_IF(E_UNEXPECTED, written >= size);
value.resize(written);
}

// The env value is delivered as UTF-8 bytes, matching how the guest exposes it at /run/secrets/<id>.
auto valueBytes = wsl::windows::common::string::WideToMultiByte(value);
return services::BuildSecret{
.Id = std::move(id),
.Value = std::vector<BYTE>(valueBytes.begin(), valueBytes.end()),
};
}

static bool TryInspectImage(Session& session, const std::string& imageId, std::optional<wslc_schema::InspectImage>& inspectData)
Comment thread
Copilot marked this conversation as resolved.
Outdated
{
try
Expand Down Expand Up @@ -105,6 +259,15 @@ void BuildImage(CLIExecutionContext& context)
validation::ParseLabel(label);
}

std::vector<services::BuildSecret> secrets;
if (context.Args.Contains(ArgType::Secret))
{
for (const auto& spec : context.Args.GetAll<ArgType::Secret>())
{
secrets.push_back(ParseSecretSpec(spec));
}
}

std::wstring dockerfilePath;
if (context.Args.Contains(ArgType::File))
{
Expand All @@ -126,7 +289,7 @@ void BuildImage(CLIExecutionContext& context)

auto cancelEvent = context.CreateCancelEvent();
BuildImageCallback callback(cancelEvent, context.Args.Contains(ArgType::Verbose));
services::ImageService::Build(session, contextPath, tags, buildArgs, labels, dockerfilePath, target, flags, &callback, cancelEvent);
services::ImageService::Build(session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, flags, &callback, cancelEvent);
Comment thread
ggarzia-MSFT marked this conversation as resolved.
}

void GetImages(CLIExecutionContext& context)
Expand Down
Loading