Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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, file names 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 @@ -523,6 +539,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
126 changes: 126 additions & 0 deletions src/windows/wslc/arguments/ArgumentValidation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Module Name:
#include <charconv>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <format>
#include <sstream>
#include <unordered_map>
Expand Down Expand Up @@ -104,6 +105,10 @@ void Argument::Validate(const ArgMap& execArgs) const
validation::ValidateIntegerFromString<LONGLONG>(execArgs.GetAll<ArgType::Time>(), m_name);
break;

case ArgType::Secret:
validation::ValidateSecretSpec(execArgs.GetAll<ArgType::Secret>());
break;

case ArgType::Since:
validation::ValidateTimestamp(execArgs.GetAll<ArgType::Since>(), m_name);
break;
Expand Down Expand Up @@ -201,6 +206,127 @@ void ValidateWSLCSignalFromString(const std::vector<std::wstring>& values, const
}
}

void ValidateSecretSpec(const std::vector<std::wstring>& values)
{
for (const auto& spec : values)
{
std::wstring id;
std::wstring type;
std::wstring envName;
std::wstring srcPath;

for (const auto& part : Split(spec, L','))
{
auto eq = part.find(L'=');
if (eq == std::wstring::npos || eq == 0)
{
throw ArgumentException(
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;
}
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Outdated
else
{
throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported key '{}'", key)));
}
}

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

// Docker parity: 'id' may not start with '-' because that would be interpreted as a command-line option.
if (id[0] == L'-')
{
throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may not start with '-'"));
}

// 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 ArgumentException(
Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may only contain letters, digits, '_', '-' or '.'"));
}
}

if (!type.empty() && type != L"file" && type != L"env")
{
throw ArgumentException(
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 ArgumentException(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 ArgumentException(
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 validate 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 ArgumentException(Localization::MessageWslcSecretInvalidSpec(
spec, std::format(L"source file not found or not a regular file: {}", absPath.wstring())));
}
Comment thread
ggarzia-MSFT marked this conversation as resolved.
Outdated
}
}
}

void ValidateVolumeMount(const std::vector<std::wstring>& values)
{
for (const auto& value : values)
Expand Down
1 change: 1 addition & 0 deletions src/windows/wslc/arguments/ArgumentValidation.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring
InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName);

void ValidateGpus(const std::vector<std::wstring>& values, const std::wstring& argName);
void ValidateSecretSpec(const std::vector<std::wstring>& values);
void ValidateVolumeMount(const std::vector<std::wstring>& values);
void ValidateFilter(const std::vector<std::wstring>& values);

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
Loading
Loading