diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index b94a8d126..8e8ddaf6f 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2496,6 +2496,14 @@ For privacy information about this product please visit https://aka.ms/privacy.< Failed to create volume '{}': {} {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + Bind source path does not exist: '{}' + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Failed to access bind source path '{}': {} + {FixedPlaceholder="{}"}{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + Too many volumes have been mounted (limit: {}). Restart the session to mount more volumes. This will be fixed in a future release. {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated @@ -3005,6 +3013,92 @@ On first run, creates the file with all settings commented out at their defaults Invalid value "{}" for the '-f, --filter' option: bad format of filter (expected name=value) {FixedPlaceholder="{}"}{Locked="--filter'"}Command line arguments, file names and string inserts should not be translated + + Invalid argument "{}" for '--mount' option: {} + {FixedPlaceholder="{}"}{FixedPlaceholder="{}"}{Locked="--mount'"}Command line arguments, file names and string inserts should not be translated + + + Argument "{}" for '--mount' option uses an unsupported feature: {} + {FixedPlaceholder="{}"}{FixedPlaceholder="{}"}{Locked="--mount'"}Command line arguments, file names and string inserts should not be translated + + + Duplicate mount point: {} + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The CSV input is malformed. + {Locked="CSV"}Command line arguments should not be translated + + + The field '{}' must be a key=value pair. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The key '{}' is unexpected in '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The value for '{}' is invalid: '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The value for '{}' is invalid: '{}'. Valid values are "enabled", "disabled", "writable", and "readonly". + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The mount type is required. + + + Options matching '{}' cannot be used with mount type '{}'. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Option '{}' requires the 'readonly' option. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Option 'bind-recursive=readonly' requires the 'bind-propagation=rprivate' option. + {Locked="bind-recursive=readonly"}{Locked="bind-propagation=rprivate"}Command line arguments should not be translated + + + Mount type '{}' is not supported. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Option '{}' is not supported. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + The mount target is required. + + + The mount target path must be absolute. + + + The tmpfs options are supported only for tmpfs mounts. + {Locked="tmpfs"}Command line arguments should not be translated + + + The tmpfs size cannot be negative. + {Locked="tmpfs"}Command line arguments should not be translated + + + The mount source is required. + + + The bind source path must be absolute. + {Locked="bind"}Command line arguments should not be translated + + + The volume source must be a valid named volume. + + + The source option is not supported for tmpfs mounts. + {Locked="tmpfs"}Command line arguments should not be translated + + + The mount type is not supported. + Follow log output @@ -3073,6 +3167,9 @@ On first run, creates the file with all settings commented out at their defaults Memory limit (e.g. 512M, 1G) {Locked="512M"}{Locked="1G"}Command line argument example values should not be translated + + Attach a filesystem mount to the container + Container host name diff --git a/src/windows/common/CMakeLists.txt b/src/windows/common/CMakeLists.txt index 008eafab1..97fb2dfd8 100644 --- a/src/windows/common/CMakeLists.txt +++ b/src/windows/common/CMakeLists.txt @@ -26,6 +26,7 @@ set(SOURCES LxssMessagePort.cpp LxssSecurity.cpp LxssServerPort.cpp + MountSpecParsing.cpp NatNetworking.cpp notifications.cpp Redirector.cpp @@ -111,6 +112,7 @@ set(HEADERS LxssPort.h LxssSecurity.h LxssServerPort.h + MountSpecParsing.h NatNetworking.h notifications.h precomp.h diff --git a/src/windows/common/MountSpecParsing.cpp b/src/windows/common/MountSpecParsing.cpp new file mode 100644 index 000000000..1a1079829 --- /dev/null +++ b/src/windows/common/MountSpecParsing.cpp @@ -0,0 +1,690 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + MountSpecParsing.cpp + +Abstract: + + Docker-compatible mount specification parsing. + +--*/ + +#include "precomp.h" +#include "MountSpecParsing.h" +#include "string.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace wsl::shared; +using namespace wsl::shared::string; + +namespace wsl::windows::common::mount { + +namespace { + + enum class Field + { + Type, + Source, + Target, + ReadOnly, + Consistency, + BindPropagation, + BindNonRecursive, + BindRecursive, + VolumeNoCopy, + VolumeLabel, + VolumeDriver, + VolumeOption, + TmpfsSize, + TmpfsMode, + }; + + enum class Family + { + General, + Bind, + Volume, + Tmpfs, + }; + + enum class Support + { + Supported, + Unsupported, + ValueDependent, + }; + + struct FieldDefinition + { + std::wstring_view Name; + Field Id; + Family OptionFamily; + bool AllowsBareForm; + Support SupportLevel; + }; + + // Keep this table aligned with docker/cli v25.0.3 opts/mount.go. It is the + // authoritative list of accepted fields, aliases, option families, and WSLC support. + constexpr std::array c_fieldDefinitions{ + FieldDefinition{L"type", Field::Type, Family::General, false, Support::ValueDependent}, + FieldDefinition{L"source", Field::Source, Family::General, false, Support::Supported}, + FieldDefinition{L"src", Field::Source, Family::General, false, Support::Supported}, + FieldDefinition{L"target", Field::Target, Family::General, false, Support::Supported}, + FieldDefinition{L"dst", Field::Target, Family::General, false, Support::Supported}, + FieldDefinition{L"destination", Field::Target, Family::General, false, Support::Supported}, + FieldDefinition{L"readonly", Field::ReadOnly, Family::General, true, Support::Supported}, + FieldDefinition{L"ro", Field::ReadOnly, Family::General, true, Support::Supported}, + FieldDefinition{L"consistency", Field::Consistency, Family::General, false, Support::Unsupported}, + FieldDefinition{L"bind-propagation", Field::BindPropagation, Family::Bind, false, Support::Unsupported}, + FieldDefinition{L"bind-nonrecursive", Field::BindNonRecursive, Family::Bind, true, Support::Unsupported}, + FieldDefinition{L"bind-recursive", Field::BindRecursive, Family::Bind, false, Support::ValueDependent}, + FieldDefinition{L"volume-nocopy", Field::VolumeNoCopy, Family::Volume, true, Support::Unsupported}, + FieldDefinition{L"volume-label", Field::VolumeLabel, Family::Volume, false, Support::Unsupported}, + FieldDefinition{L"volume-driver", Field::VolumeDriver, Family::Volume, false, Support::Unsupported}, + FieldDefinition{L"volume-opt", Field::VolumeOption, Family::Volume, false, Support::Unsupported}, + FieldDefinition{L"tmpfs-size", Field::TmpfsSize, Family::Tmpfs, false, Support::Supported}, + FieldDefinition{L"tmpfs-mode", Field::TmpfsMode, Family::Tmpfs, false, Support::Supported}, + }; + + struct DockerMountSpec + { + std::wstring Type = L"volume"; + std::wstring Source; + std::wstring Target; + bool ReadOnly = false; + bool HasVolumeOptions = false; + bool HasBindOptions = false; + bool HasTmpfsOptions = false; + bool BindReadOnlyNonRecursive = false; + bool BindReadOnlyForceRecursive = false; + std::wstring BindPropagation; + std::optional TmpfsSizeBytes; + std::optional TmpfsMode; + std::optional UnsupportedOption; + }; + + struct KeyValue + { + std::wstring Key; + std::wstring Value; + bool HadSeparator; + }; + + [[noreturn]] void ThrowValidation(std::wstring reason) + { + throw MountValidationException(std::move(reason)); + } + + [[noreturn]] void ThrowParse(std::wstring reason) + { + throw MountParseException(std::move(reason)); + } + + [[noreturn]] void ThrowUnsupported(std::wstring reason) + { + throw MountUnsupportedException(std::move(reason)); + } + + KeyValue SplitKeyValue(const std::wstring& value) + { + const auto position = value.find(L'='); + if (position == std::wstring::npos) + { + return {.Key = value, .HadSeparator = false}; + } + + return {.Key = value.substr(0, position), .Value = value.substr(position + 1), .HadSeparator = true}; + } + + const FieldDefinition* FindField(std::wstring_view name) + { + const auto found = std::ranges::find_if(c_fieldDefinitions, [&](const auto& definition) { return definition.Name == name; }); + return found == c_fieldDefinitions.end() ? nullptr : &*found; + } + + void RecordUnsupportedOption(DockerMountSpec& mount, std::wstring_view option) + { + if (!mount.UnsupportedOption.has_value()) + { + mount.UnsupportedOption = option; + } + } + + std::optional ParseDockerRamInBytes(const std::wstring& value) + { + const auto parsed = wsl::windows::common::string::ParseStorageSize(value, wsl::windows::common::string::StorageSizeUnit::Binary); + if (!parsed.has_value() || parsed.value() > static_cast(std::numeric_limits::max())) + { + return std::nullopt; + } + + return static_cast(parsed.value()); + } + + std::optional ParseDockerTmpfsMode(const std::wstring& value) + { + if (value.empty() || value.front() == L'-') + { + return std::nullopt; + } + + size_t position = value.front() == L'+' ? 1 : 0; + if (position == value.size()) + { + return std::nullopt; + } + + uint64_t result = 0; + for (; position < value.size(); ++position) + { + const auto digit = value[position]; + if (digit < L'0' || digit > L'7') + { + return std::nullopt; + } + + result = (result * 8) + static_cast(digit - L'0'); + if (result > std::numeric_limits::max()) + { + return std::nullopt; + } + } + + return static_cast(result); + } + + std::string FormatDockerTmpfsSize(int64_t sizeBytes) + { + for (const auto& [suffix, divisor] : std::array, 3>{{{'g', 1LL << 30}, {'m', 1LL << 20}, {'k', 1LL << 10}}}) + { + if ((sizeBytes % divisor) == 0) + { + return std::format("{}{}", sizeBytes / divisor, suffix); + } + } + + return std::to_string(sizeBytes); + } + +} // namespace + +Spec ParseDockerMountString(const std::wstring& value) +{ + const auto fields = SplitCsvFields(value); + if (!fields.has_value()) + { + ThrowParse(Localization::WSLCCLI_MountMalformedCsvError()); + } + + DockerMountSpec mount; + + for (const auto& field : *fields) + { + const auto keyValue = SplitKeyValue(field); + const auto key = AsciiToLower(std::wstring_view(keyValue.Key)); + const auto definition = FindField(key); + if (definition == nullptr) + { + if (!keyValue.HadSeparator) + { + ThrowParse(Localization::WSLCCLI_MountFieldKeyValueRequiredError(field)); + } + + ThrowParse(Localization::WSLCCLI_MountUnexpectedKeyError(key, field)); + } + + if (!keyValue.HadSeparator && !definition->AllowsBareForm) + { + ThrowParse(Localization::WSLCCLI_MountFieldKeyValueRequiredError(field)); + } + + switch (definition->OptionFamily) + { + case Family::General: + break; + case Family::Bind: + mount.HasBindOptions = true; + break; + case Family::Volume: + mount.HasVolumeOptions = true; + break; + case Family::Tmpfs: + mount.HasTmpfsOptions = true; + break; + } + + switch (definition->Id) + { + case Field::Type: + mount.Type = AsciiToLower(std::wstring_view(keyValue.Value)); + break; + + case Field::Source: + mount.Source = keyValue.Value; + if (mount.Source == L"." || mount.Source.starts_with(L".\\")) + { + std::error_code error; + auto absolutePath = std::filesystem::absolute(mount.Source, error); + if (!error) + { + mount.Source = absolutePath.lexically_normal().wstring(); + } + } + break; + + case Field::Target: + mount.Target = keyValue.Value; + break; + + case Field::ReadOnly: + if (!keyValue.HadSeparator) + { + mount.ReadOnly = true; + break; + } + + if (const auto parsed = ParseBool(keyValue.Value.c_str(), true); parsed.has_value()) + { + mount.ReadOnly = parsed.value(); + } + else + { + ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value)); + } + break; + + case Field::Consistency: + break; + + case Field::BindPropagation: + mount.BindPropagation = AsciiToLower(std::wstring_view(keyValue.Value)); + break; + + case Field::BindNonRecursive: + if (keyValue.HadSeparator && !ParseBool(keyValue.Value.c_str(), true).has_value()) + { + ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value)); + } + + break; + + case Field::BindRecursive: + if (keyValue.Value == L"enabled") + { + break; + } + + RecordUnsupportedOption(mount, key); + if (keyValue.Value == L"disabled") + { + break; + } + if (keyValue.Value == L"writable") + { + mount.BindReadOnlyNonRecursive = true; + break; + } + if (keyValue.Value == L"readonly") + { + mount.BindReadOnlyForceRecursive = true; + break; + } + + ThrowParse(Localization::WSLCCLI_MountInvalidBindRecursiveValueError(key, keyValue.Value)); + + case Field::VolumeNoCopy: + if (keyValue.HadSeparator && !ParseBool(keyValue.Value.c_str(), true).has_value()) + { + ThrowParse(Localization::WSLCCLI_MountInvalidValueError(L"volume-nocopy", keyValue.Value)); + } + + break; + + case Field::VolumeLabel: + case Field::VolumeDriver: + case Field::VolumeOption: + break; + + case Field::TmpfsSize: + mount.TmpfsSizeBytes = ParseDockerRamInBytes(keyValue.Value); + if (!mount.TmpfsSizeBytes.has_value()) + { + ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value)); + } + + break; + + case Field::TmpfsMode: + mount.TmpfsMode = ParseDockerTmpfsMode(keyValue.Value); + if (!mount.TmpfsMode.has_value()) + { + ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value)); + } + + break; + } + + if (definition->SupportLevel == Support::Unsupported) + { + RecordUnsupportedOption(mount, key); + } + } + + if (mount.Type.empty()) + { + ThrowParse(Localization::WSLCCLI_MountTypeRequiredError()); + } + + if (mount.HasVolumeOptions && mount.Type != L"volume") + { + ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"volume-*", mount.Type)); + } + if (mount.HasBindOptions && mount.Type != L"bind") + { + ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"bind-*", mount.Type)); + } + if (mount.HasTmpfsOptions && mount.Type != L"tmpfs") + { + ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"tmpfs-*", mount.Type)); + } + + if (mount.BindReadOnlyNonRecursive && !mount.ReadOnly) + { + ThrowParse(Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=writable")); + } + if (mount.BindReadOnlyForceRecursive) + { + if (!mount.ReadOnly) + { + ThrowParse(Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=readonly")); + } + if (mount.BindPropagation != L"rprivate") + { + ThrowParse(Localization::WSLCCLI_MountBindRecursiveReadonlyRequiresPropagationError()); + } + } + + Type type; + if (mount.Type == L"bind") + { + type = Type::Bind; + } + else if (mount.Type == L"volume") + { + type = Type::Volume; + } + else if (mount.Type == L"tmpfs") + { + type = Type::Tmpfs; + } + else + { + ThrowUnsupported(Localization::WSLCCLI_MountTypeUnsupportedError(mount.Type)); + } + + if (mount.UnsupportedOption.has_value()) + { + ThrowUnsupported(Localization::WSLCCLI_MountOptionUnsupportedError(mount.UnsupportedOption.value())); + } + + return { + .MountType = type, + .Source = std::move(mount.Source), + .Target = WideToMultiByte(mount.Target), + .ReadOnly = mount.ReadOnly, + .TmpfsSizeBytes = mount.TmpfsSizeBytes, + .TmpfsMode = mount.TmpfsMode, + }; +} + +Spec ParseDockerVolumeString(const std::wstring& value) +{ + const auto formatUsage = Localization::WSLCCLI_VolumeFormatUsage(); + const auto lastColon = value.rfind(L':'); + if (lastColon == std::wstring::npos) + { + ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage)); + } + + auto splitColon = lastColon; + bool readOnly = false; + std::wstring_view lastToken{value.data() + lastColon + 1, value.size() - lastColon - 1}; + if (lastToken == L"ro" || lastToken == L"rw") + { + readOnly = lastToken == L"ro"; + if (lastColon == 0) + { + ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage)); + } + + splitColon = value.rfind(L':', lastColon - 1); + if (splitColon == std::wstring::npos) + { + ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage)); + } + } + + const auto targetEnd = lastToken == L"ro" || lastToken == L"rw" ? lastColon : value.size(); + const auto target = value.substr(splitColon + 1, targetEnd - splitColon - 1); + if (target.empty()) + { + ThrowParse(Localization::WSLCCLI_VolumeContainerPathEmpty(value, formatUsage)); + } + + if (target.front() != L'/') + { + ThrowParse(Localization::WSLCCLI_VolumeContainerPathNotAbsolute(value, formatUsage)); + } + + const auto rawSource = value.substr(0, splitColon); + if (rawSource.empty()) + { + ThrowParse(Localization::WSLCCLI_VolumeHostPathEmpty(value, formatUsage)); + } + + if (IsValidNamedVolumeName(rawSource)) + { + return { + .MountType = Type::Volume, + .Source = rawSource, + .Target = WideToMultiByte(target), + .ReadOnly = readOnly, + }; + } + + std::wstring source; + if (FAILED(wil::GetFullPathNameW(rawSource.c_str(), source))) + { + ThrowParse(Localization::WSLCCLI_VolumeHostPathInvalid(value, rawSource)); + } + + if (GetFileAttributesW(source.c_str()) == INVALID_FILE_ATTRIBUTES && GetLastError() == ERROR_INVALID_NAME) + { + ThrowParse(Localization::WSLCCLI_VolumeHostPathInvalid(value, rawSource)); + } + + return { + .MountType = Type::Bind, + .Source = std::move(source), + .Target = WideToMultiByte(target), + .ReadOnly = readOnly, + .BindSource = BindSourcePolicy::CreateIfMissing, + }; +} + +Spec ParseDockerTmpfsString(const std::wstring& value) +{ + const auto colon = value.find(L':'); + const auto target = value.substr(0, colon); + const auto options = colon == std::wstring::npos ? std::wstring_view{} : std::wstring_view{value}.substr(colon + 1); + + return { + .MountType = Type::Tmpfs, + .Target = WideToMultiByte(target), + .TmpfsOptions = WideToMultiByte(std::wstring{options}), + }; +} + +void ValidateMountSpec(const Spec& mount) +{ + if (mount.Target.empty()) + { + ThrowValidation(Localization::WSLCCLI_MountTargetRequiredError()); + } + + if (!mount.Target.starts_with('/')) + { + ThrowValidation(Localization::WSLCCLI_MountTargetAbsoluteError()); + } + + if (mount.MountType != Type::Tmpfs && (mount.TmpfsSizeBytes.has_value() || mount.TmpfsMode.has_value() || mount.TmpfsOptions.has_value())) + { + ThrowValidation(Localization::WSLCCLI_MountTmpfsOptionsTypeError()); + } + + if (mount.TmpfsSizeBytes.has_value() && mount.TmpfsSizeBytes.value() < 0) + { + ThrowValidation(Localization::WSLCCLI_MountTmpfsSizeNegativeError()); + } + + switch (mount.MountType) + { + case Type::Bind: + if (mount.Source.empty()) + { + ThrowValidation(Localization::WSLCCLI_MountSourceRequiredError()); + } + + if (!std::filesystem::path(mount.Source).is_absolute()) + { + ThrowValidation(Localization::WSLCCLI_MountBindSourceAbsoluteError()); + } + break; + + case Type::Volume: + if (!mount.Source.empty() && !IsValidNamedVolumeName(mount.Source)) + { + ThrowValidation(Localization::WSLCCLI_MountVolumeSourceInvalidError()); + } + break; + + case Type::Tmpfs: + if (!mount.Source.empty()) + { + ThrowValidation(Localization::WSLCCLI_MountTmpfsSourceUnsupportedError()); + } + break; + + default: + ThrowUnsupported(Localization::WSLCCLI_MountTypeUnsupportedGenericError()); + } +} + +void ValidateMountCollection(std::span mounts) +{ + std::unordered_set destinations; + for (const auto& mount : mounts) + { + ValidateMountSpec(mount); + + auto destination = NormalizeDestination(mount.Target); + if (!destinations.emplace(destination).second) + { + throw MountValidationException( + ValidationError::DuplicateDestination, + Localization::WSLCCLI_DuplicateMountDestinationError(MultiByteToWide(destination)), + std::move(destination)); + } + } +} + +std::string FormatTmpfsOptions(const Spec& mount) +{ + WI_ASSERT(mount.MountType == Type::Tmpfs); + + if (mount.TmpfsOptions.has_value()) + { + return mount.TmpfsOptions.value(); + } + + std::vector options; + if (mount.ReadOnly) + { + options.emplace_back("ro"); + } + if (mount.TmpfsMode.has_value() && mount.TmpfsMode.value() != 0) + { + options.emplace_back(std::format("mode={:o}", mount.TmpfsMode.value())); + } + if (mount.TmpfsSizeBytes.has_value() && mount.TmpfsSizeBytes.value() != 0) + { + options.emplace_back(std::format("size={}", FormatDockerTmpfsSize(mount.TmpfsSizeBytes.value()))); + } + + return wsl::shared::string::Join(options, ','); +} + +std::string NormalizeDestination(std::string destination) +{ + std::replace(destination.begin(), destination.end(), '\\', '/'); + + std::vector components; + size_t start = 0; + while (start <= destination.size()) + { + const auto end = destination.find('/', start); + const auto component = destination.substr(start, end - start); + if (!component.empty() && component != ".") + { + if (component == "..") + { + if (!components.empty()) + { + components.pop_back(); + } + } + else + { + components.emplace_back(component); + } + } + + if (end == std::string::npos) + { + break; + } + + start = end + 1; + } + + std::string result = "/"; + for (const auto& component : components) + { + if (result.size() > 1) + { + result += '/'; + } + + result += component; + } + + return result; +} + +bool IsValidNamedVolumeName(std::wstring_view name) +{ + static const std::wregex c_namedVolumeRegex(LR"(^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,}$)"); + return std::regex_match(name.begin(), name.end(), c_namedVolumeRegex); +} + +} // namespace wsl::windows::common::mount diff --git a/src/windows/common/MountSpecParsing.h b/src/windows/common/MountSpecParsing.h new file mode 100644 index 000000000..84f76aada --- /dev/null +++ b/src/windows/common/MountSpecParsing.h @@ -0,0 +1,125 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + MountSpecParsing.h + +Abstract: + + Docker-compatible mount specification parsing. + +--*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace wsl::windows::common::mount { + +inline constexpr std::string_view c_dockerCliMountGrammarVersion = "25.0.3"; + +enum class Type +{ + Bind, + Volume, + Tmpfs, +}; + +enum class BindSourcePolicy +{ + RequireExisting, + CreateIfMissing, +}; + +struct Spec +{ + Type MountType = Type::Volume; + std::wstring Source; + std::string Target; + bool ReadOnly = false; + BindSourcePolicy BindSource = BindSourcePolicy::RequireExisting; + std::optional TmpfsSizeBytes; + std::optional TmpfsMode; + std::optional TmpfsOptions; +}; + +enum class ValidationError +{ + InvalidSpecification, + DuplicateDestination, +}; + +class MountException : public std::exception +{ +public: + explicit MountException(std::wstring reason) : m_reason(std::move(reason)) + { + } + + MountException(ValidationError error, std::wstring reason, std::string destination) : + m_error(error), m_reason(std::move(reason)), m_destination(std::move(destination)) + { + } + + const char* what() const noexcept override + { + return "mount error"; + } + + const std::wstring& Reason() const noexcept + { + return m_reason; + } + + ValidationError Error() const noexcept + { + return m_error; + } + + const std::string& Destination() const noexcept + { + return m_destination; + } + +private: + ValidationError m_error = ValidationError::InvalidSpecification; + std::wstring m_reason; + std::string m_destination; +}; + +class MountParseException : public MountException +{ +public: + using MountException::MountException; +}; + +class MountUnsupportedException : public MountException +{ +public: + using MountException::MountException; +}; + +class MountValidationException : public MountException +{ +public: + using MountException::MountException; +}; + +Spec ParseDockerMountString(const std::wstring& value); +Spec ParseDockerVolumeString(const std::wstring& value); +Spec ParseDockerTmpfsString(const std::wstring& value); +void ValidateMountSpec(const Spec& mount); +void ValidateMountCollection(std::span mounts); +std::string FormatTmpfsOptions(const Spec& mount); +std::string NormalizeDestination(std::string destination); +bool IsValidNamedVolumeName(std::wstring_view name); + +} // namespace wsl::windows::common::mount diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp index 57a96e3d9..59e488d68 100644 --- a/src/windows/common/WSLCContainerLauncher.cpp +++ b/src/windows/common/WSLCContainerLauncher.cpp @@ -229,29 +229,74 @@ void WSLCContainerLauncher::AddUlimit(const std::string& Name, std::int64_t Soft void wsl::windows::common::WSLCContainerLauncher::AddVolume(const std::wstring& HostPath, const std::string& ContainerPath, bool ReadOnly) { - // Store a copy of the path strings to the launcher to ensure the pointers in WSLCVolume remain valid. - const auto& hostPath = m_hostPaths.emplace_back(HostPath); - const auto& containerPath = m_containerPaths.emplace_back(ContainerPath); - - WSLCVolume vol{}; - vol.HostPath = hostPath.c_str(); - vol.ContainerPath = containerPath.c_str(); - vol.ReadOnly = ReadOnly ? TRUE : FALSE; - - m_volumes.push_back(vol); + AddMount({ + .MountType = mount::Type::Bind, + .Source = HostPath, + .Target = ContainerPath, + .ReadOnly = ReadOnly, + .BindSource = mount::BindSourcePolicy::CreateIfMissing, + }); } void wsl::windows::common::WSLCContainerLauncher::AddNamedVolume(const std::string& Name, const std::string& ContainerPath, bool ReadOnly) { - const auto& name = m_volumeNames.emplace_back(Name); - const auto& containerPath = m_containerPaths.emplace_back(ContainerPath); + AddMount({ + .MountType = mount::Type::Volume, + .Source = wsl::shared::string::MultiByteToWide(Name), + .Target = ContainerPath, + .ReadOnly = ReadOnly, + }); +} + +void wsl::windows::common::WSLCContainerLauncher::AddMount(const mount::Spec& Mount) +{ + WSLCMountSpec mount{}; + switch (Mount.MountType) + { + case mount::Type::Bind: + mount.Type = WSLCMountTypeBind; + break; + + case mount::Type::Volume: + mount.Type = WSLCMountTypeVolume; + break; - WSLCNamedVolume volume{}; - volume.Name = name.c_str(); - volume.ContainerPath = containerPath.c_str(); - volume.ReadOnly = ReadOnly ? TRUE : FALSE; + case mount::Type::Tmpfs: + mount.Type = WSLCMountTypeTmpfs; + break; + } + + if (!Mount.Source.empty()) + { + mount.Source = m_mountSources.emplace_back(Mount.Source).c_str(); + } + + mount.Target = m_mountTargets.emplace_back(Mount.Target).c_str(); + mount.ReadOnly = Mount.ReadOnly ? TRUE : FALSE; + if (Mount.MountType == mount::Type::Bind && Mount.BindSource == mount::BindSourcePolicy::CreateIfMissing) + { + WI_SetFlag(mount.Flags, WSLCMountSpecFlagsCreateSourceIfMissing); + } - m_namedVolumes.push_back(volume); + if (Mount.TmpfsSizeBytes.has_value()) + { + WI_SetFlag(mount.Flags, WSLCMountSpecFlagsTmpfsSize); + mount.TmpfsSizeBytes = Mount.TmpfsSizeBytes.value(); + } + + if (Mount.TmpfsMode.has_value()) + { + WI_SetFlag(mount.Flags, WSLCMountSpecFlagsTmpfsMode); + mount.TmpfsMode = Mount.TmpfsMode.value(); + } + + if (Mount.TmpfsOptions.has_value()) + { + WI_SetFlag(mount.Flags, WSLCMountSpecFlagsTmpfsOptions); + mount.TmpfsOptions = m_mountTmpfsOptions.emplace_back(Mount.TmpfsOptions.value()).c_str(); + } + + m_mounts.push_back(mount); } void wsl::windows::common::WSLCContainerLauncher::AddLabel(const std::string& Key, const std::string& Value) @@ -269,15 +314,11 @@ void wsl::windows::common::WSLCContainerLauncher::AddLabel(const std::string& Ke void wsl::windows::common::WSLCContainerLauncher::AddTmpfs(const std::string& ContainerPath, const std::string& Options) { - // Store a copy of the path/options strings to the launcher to ensure the pointers in WSLCTmpfsMount remain valid. - const auto& containerPath = m_tmpfsContainerPaths.emplace_back(ContainerPath); - const auto& options = m_tmpfsOptions.emplace_back(Options); - - WSLCTmpfsMount tmpfs{}; - tmpfs.Destination = containerPath.c_str(); - tmpfs.Options = options.c_str(); - - m_tmpfsMounts.push_back(tmpfs); + AddMount({ + .MountType = mount::Type::Tmpfs, + .Target = ContainerPath, + .TmpfsOptions = Options, + }); } void wsl::windows::common::WSLCContainerLauncher::AddAdditionalNetwork(const std::string& Name) @@ -412,18 +453,12 @@ std::pair> WSLCContainerLauncher::C options.InitProcessOptions.CurrentDirectory = m_workingDirectory.c_str(); } - options.VolumesCount = static_cast(m_volumes.size()); - options.Volumes = m_volumes.size() > 0 ? m_volumes.data() : nullptr; - - options.NamedVolumesCount = static_cast(m_namedVolumes.size()); - options.NamedVolumes = m_namedVolumes.size() > 0 ? m_namedVolumes.data() : nullptr; + options.MountsCount = static_cast(m_mounts.size()); + options.Mounts = m_mounts.size() > 0 ? m_mounts.data() : nullptr; options.LabelsCount = static_cast(m_labels.size()); options.Labels = m_labels.size() > 0 ? m_labels.data() : nullptr; - options.TmpfsCount = static_cast(m_tmpfsMounts.size()); - options.Tmpfs = m_tmpfsMounts.size() > 0 ? m_tmpfsMounts.data() : nullptr; - options.ContainerNetwork.NetworkMode = m_networkMode.c_str(); // Each additional network becomes an entry in NetworkingConfig.EndpointsConfig. diff --git a/src/windows/common/WSLCContainerLauncher.h b/src/windows/common/WSLCContainerLauncher.h index 0ad00956c..52e6e2997 100644 --- a/src/windows/common/WSLCContainerLauncher.h +++ b/src/windows/common/WSLCContainerLauncher.h @@ -13,6 +13,7 @@ Module Name: --*/ #pragma once +#include "MountSpecParsing.h" #include "WSLCProcessLauncher.h" #include "docker_schema.h" #include "wslc_schema.h" @@ -59,6 +60,7 @@ class WSLCContainerLauncher : private WSLCProcessLauncher void AddVolume(const std::wstring& HostPath, const std::string& ContainerPath, bool ReadOnly); void AddNamedVolume(const std::string& Name, const std::string& ContainerPath, bool ReadOnly); + void AddMount(const mount::Spec& Mount); void AddPort(uint16_t WindowsPort, uint16_t ContainerPort, int Family, int Protocol = IPPROTO_TCP, const std::optional& BindingAddress = {}); void AddLabel(const std::string& Key, const std::string& Value); void AddTmpfs(const std::string& ContainerPath, const std::string& Options); @@ -109,11 +111,10 @@ class WSLCContainerLauncher : private WSLCProcessLauncher std::string m_image; std::string m_name; std::vector m_ports; - std::vector m_volumes; - std::vector m_namedVolumes; - std::deque m_hostPaths; - std::deque m_volumeNames; - std::deque m_containerPaths; + std::vector m_mounts; + std::deque m_mountSources; + std::deque m_mountTargets; + std::deque m_mountTmpfsOptions; std::string m_networkMode; std::vector m_entrypoint; WSLCSignal m_stopSignal = WSLCSignalNone; @@ -135,9 +136,6 @@ class WSLCContainerLauncher : private WSLCProcessLauncher std::vector m_labels; std::deque m_labelKeys; std::deque m_labelValues; - std::vector m_tmpfsMounts; - std::deque m_tmpfsContainerPaths; - std::deque m_tmpfsOptions; std::int64_t m_memoryBytes = 0; std::int64_t m_nanoCpus = 0; std::vector m_ulimits; diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 4c1f23cbf..be76ebfed 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -239,6 +239,14 @@ inline void to_json(nlohmann::json& j, const ContainerNetworkRequest& v) } } +struct MountTmpfsOptions +{ + std::int64_t SizeBytes{}; + std::uint32_t Mode{}; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(MountTmpfsOptions, SizeBytes, Mode); +}; + struct Mount { std::string Name; @@ -246,8 +254,9 @@ struct Mount std::string Target; std::string Type; bool ReadOnly{}; + std::optional TmpfsOptions; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Mount, Name, Target, Source, Type, ReadOnly); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Mount, Name, Target, Source, Type, ReadOnly, TmpfsOptions); }; struct DeviceMapping diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 52e997346..e6d1b2be1 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -245,6 +245,37 @@ typedef struct _WSLCTmpfsMount [unique] LPCSTR Options; } WSLCTmpfsMount; +typedef enum _WSLCMountType +{ + WSLCMountTypeBind, + WSLCMountTypeVolume, + WSLCMountTypeTmpfs, +} WSLCMountType; + +typedef enum _WSLCMountSpecFlags +{ + WSLCMountSpecFlagsNone = 0, + WSLCMountSpecFlagsTmpfsSize = 1, + WSLCMountSpecFlagsTmpfsMode = 2, + WSLCMountSpecFlagsCreateSourceIfMissing = 4, + WSLCMountSpecFlagsTmpfsOptions = 8, +} WSLCMountSpecFlags; + +cpp_quote("#define WSLCMountSpecFlagsValid (WSLCMountSpecFlagsTmpfsSize | WSLCMountSpecFlagsTmpfsMode | WSLCMountSpecFlagsCreateSourceIfMissing | WSLCMountSpecFlagsTmpfsOptions)") +cpp_quote("DEFINE_ENUM_FLAG_OPERATORS(WSLCMountSpecFlags);") + +typedef struct _WSLCMountSpec +{ + WSLCMountType Type; + [unique, string] LPCWSTR Source; + [string] LPCSTR Target; + BOOL ReadOnly; + WSLCMountSpecFlags Flags; + LONGLONG TmpfsSizeBytes; + ULONG TmpfsMode; + [unique, string] LPCSTR TmpfsOptions; +} WSLCMountSpec; + typedef struct _WSLCUlimit { [string] LPCSTR Name; @@ -328,6 +359,9 @@ typedef struct _WSLCContainerOptions LONGLONG HealthTimeoutNs; LONGLONG HealthStartPeriodNs; LONG HealthRetries; + + [unique, size_is(MountsCount)] const WSLCMountSpec* Mounts; + ULONG MountsCount; } WSLCContainerOptions; typedef char WSLCContainerId[WSLC_CONTAINER_ID_LENGTH + 1] ; diff --git a/src/windows/wslc/arguments/ArgumentConvertedTypes.h b/src/windows/wslc/arguments/ArgumentConvertedTypes.h index a495ff9a9..cae8b4919 100644 --- a/src/windows/wslc/arguments/ArgumentConvertedTypes.h +++ b/src/windows/wslc/arguments/ArgumentConvertedTypes.h @@ -18,6 +18,7 @@ Module Name: #include "ArgumentTypes.h" #include "ContainerModel.h" #include "InspectModel.h" +#include "MountSpecParsing.h" #include "SpecParsing.h" #include @@ -33,6 +34,8 @@ struct BuildSecret; namespace wsl::windows::wslc::argument::details { +namespace mount = wsl::windows::common::mount; + // Local aliases so the ConvertedType tokens in the WSLC_ARGUMENTS X-macro (ArgumentDefinitions.h) // resolve here regardless of include order. Aggregate converted types must be aliased because their // commas would otherwise break X-macro argument parsing if written inline in the table. @@ -47,6 +50,7 @@ using UlimitValue = std::tuple; using KeyValuePair = std::pair; using BuildOutput = wsl::windows::wslc::services::BuildOutput; using BuildSecret = wsl::windows::wslc::services::BuildSecret; +using ParsedMount = mount::Spec; // Generate the ArgType -> converted type mapping from the X-macro. Every ArgType gets a // specialization; arguments that are not converted map to NoConversion (their raw string is used diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index 618efcbaa..3a37150cb 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -93,7 +93,8 @@ _(Latest, "latest", L"l", Kind::Flag, _(Link, "link", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_LinkArgDescription()) \ _(LinkLocalIp, "link-local-ip", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_LinkLocalIpArgDescription()) \ _(Memory, "memory", L"m", Kind::Value, int64_t, Localization::WSLCCLI_MemoryArgDescription()) \ -_(Name, "name", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_NameArgDescription()) \ +_(Mount, "mount", NO_ALIAS, Kind::Value, ParsedMount, Localization::WSLCCLI_MountArgDescription()) \ +_(Name, "name", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_NameArgDescription()) \ _(Network, "network", NO_ALIAS, Kind::Value, ParsedNetworkArgument, Localization::WSLCCLI_NetworkArgDescription()) \ _(NetworkAlias, "network-alias", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_NetworkAliasArgDescription()) \ _(NetworkName, "network-name", NO_ALIAS, Kind::Positional, NoConversion, Localization::WSLCCLI_NetworkNameArgDescription()) \ @@ -130,7 +131,7 @@ _(Tail, "tail", L"n", Kind::Value, _(Tag, "tag", L"t", Kind::Value, NoConversion, Localization::WSLCCLI_TagArgDescription()) \ _(Target, "target", NO_ALIAS, Kind::Positional, NoConversion, Localization::WSLCCLI_TargetArgDescription()) \ _(Time, "time", L"t", Kind::Value, LONG, Localization::WSLCCLI_TimeArgDescription()) \ -_(TMPFS, "tmpfs", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_TMPFSArgDescription()) \ +_(TMPFS, "tmpfs", NO_ALIAS, Kind::Value, ParsedMount, Localization::WSLCCLI_TMPFSArgDescription()) \ _(TTY, "tty", L"t", Kind::Flag, NoConversion, Localization::WSLCCLI_TTYArgDescription()) \ _(Type, "type", L"t", Kind::Value, InspectType, Localization::WSLCCLI_TypeArgDescription()) \ _(Ulimit, "ulimit", NO_ALIAS, Kind::Value, UlimitValue, Localization::WSLCCLI_UlimitArgDescription()) \ @@ -139,7 +140,7 @@ _(Username, "username", L"u", Kind::Value, _(Verbose, "verbose", NO_ALIAS, Kind::Flag, NoConversion, Localization::WSLCCLI_VerboseArgDescription()) \ _(Version, "version", L"v", Kind::Flag, NoConversion, Localization::WSLCCLI_VersionArgDescription()) \ /*_(Virtual, "virtualization", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_VirtualArgDescription())*/ \ -_(Volume, "volume", L"v", Kind::Value, NoConversion, Localization::WSLCCLI_VolumeArgDescription()) \ +_(Volume, "volume", L"v", Kind::Value, ParsedMount, Localization::WSLCCLI_VolumeArgDescription()) \ _(VolumeName, "volume-name", NO_ALIAS, Kind::Positional, NoConversion, Localization::WSLCCLI_VolumeNameArgDescription()) \ _(Volumes, "volumes", L"v", Kind::Flag, NoConversion, Localization::WSLCCLI_RemoveVolumesArgDescription()) \ _(WorkDir, "workdir", L"w", Kind::Value, NoConversion, Localization::WSLCCLI_WorkingDirArgDescription()) \ diff --git a/src/windows/wslc/arguments/ArgumentValidation.cpp b/src/windows/wslc/arguments/ArgumentValidation.cpp index 61e9a0393..c919844ee 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.cpp +++ b/src/windows/wslc/arguments/ArgumentValidation.cpp @@ -20,6 +20,7 @@ Module Name: #include "Exceptions.h" #include "ImageService.h" #include "Localization.h" +#include "MountSpecParsing.h" #include #include #include @@ -31,6 +32,8 @@ using namespace wsl::shared::string; namespace wsl::windows::wslc { +namespace mount = wsl::windows::common::mount; + namespace argument::details { struct RawArgMapAccess { @@ -228,7 +231,52 @@ void Argument::Validate(ArgMap& execArgs) const break; case ArgType::Volume: - validation::ValidateVolumeMount(RawArgMapAccess::GetAll(execArgs)); + CacheConverted(execArgs, m_name, [](const std::wstring& value, const std::wstring&) { + try + { + auto mountSpec = mount::ParseDockerVolumeString(value); + mount::ValidateMountSpec(mountSpec); + return mountSpec; + } + catch (const mount::MountException& ex) + { + throw ArgumentException(ex.Reason()); + } + }); + break; + + case ArgType::TMPFS: + CacheConverted(execArgs, m_name, [](const std::wstring& value, const std::wstring&) { + try + { + auto mountSpec = mount::ParseDockerTmpfsString(value); + mount::ValidateMountSpec(mountSpec); + return mountSpec; + } + catch (const mount::MountException& ex) + { + throw ArgumentException(ex.Reason()); + } + }); + break; + + case ArgType::Mount: + CacheConverted(execArgs, m_name, [](const std::wstring& value, const std::wstring&) { + try + { + auto mountSpec = mount::ParseDockerMountString(value); + mount::ValidateMountSpec(mountSpec); + return mountSpec; + } + catch (const mount::MountUnsupportedException& ex) + { + throw ArgumentException(Localization::WSLCCLI_UnsupportedMountError(value, ex.Reason())); + } + catch (const mount::MountException& ex) + { + throw ArgumentException(Localization::WSLCCLI_InvalidMountError(value, ex.Reason())); + } + }); break; case ArgType::WorkDir: @@ -304,14 +352,6 @@ void ValidateWSLCSignalFromString(const std::vector& values, const } } -void ValidateVolumeMount(const std::vector& values) -{ - for (const auto& value : values) - { - std::ignore = models::VolumeMount::Parse(value); - } -} - // Validates that each --filter argument is in the form "key=value". Rejects entries without an '='; // the runtime validates the key and value for specific objects. void ValidateFilter(const std::vector& values) diff --git a/src/windows/wslc/arguments/ArgumentValidation.h b/src/windows/wslc/arguments/ArgumentValidation.h index 880d344ae..f0b84f626 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.h +++ b/src/windows/wslc/arguments/ArgumentValidation.h @@ -78,7 +78,6 @@ void ValidateFormatTypeFromString(const std::vector& values, const void ValidateGpus(const std::vector& values, const std::wstring& argName); -void ValidateVolumeMount(const std::vector& values); void ValidateFilter(const std::vector& values); } // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/commands/ContainerCreateCommand.cpp b/src/windows/wslc/commands/ContainerCreateCommand.cpp index 89125ac0c..04f5ffa06 100644 --- a/src/windows/wslc/commands/ContainerCreateCommand.cpp +++ b/src/windows/wslc/commands/ContainerCreateCommand.cpp @@ -52,6 +52,7 @@ std::vector ContainerCreateCommand::GetArguments() const Argument::Create(ArgType::Interactive), Argument::Create(ArgType::Label, false, Limit::Unlimited), Argument::Create(ArgType::Memory), + Argument::Create(ArgType::Mount, false, Limit::Unlimited), Argument::Create(ArgType::Name), Argument::Create(ArgType::Network, false, Limit::Unlimited), Argument::Create(ArgType::NetworkAlias, false, Limit::Unlimited), diff --git a/src/windows/wslc/commands/ContainerRunCommand.cpp b/src/windows/wslc/commands/ContainerRunCommand.cpp index c636e57c9..33b93e359 100644 --- a/src/windows/wslc/commands/ContainerRunCommand.cpp +++ b/src/windows/wslc/commands/ContainerRunCommand.cpp @@ -52,6 +52,7 @@ std::vector ContainerRunCommand::GetArguments() const Argument::Create(ArgType::Interactive), Argument::Create(ArgType::Label, false, Limit::Unlimited), Argument::Create(ArgType::Memory), + Argument::Create(ArgType::Mount, false, Limit::Unlimited), Argument::Create(ArgType::Name), Argument::Create(ArgType::Network, false, Limit::Unlimited), Argument::Create(ArgType::NetworkAlias, false, Limit::Unlimited), diff --git a/src/windows/wslc/services/ContainerModel.cpp b/src/windows/wslc/services/ContainerModel.cpp index baaaa562f..d1b3018b9 100644 --- a/src/windows/wslc/services/ContainerModel.cpp +++ b/src/windows/wslc/services/ContainerModel.cpp @@ -13,6 +13,7 @@ Module Name: #include "precomp.h" #include "ContainerModel.h" +#include namespace wsl::windows::wslc::models { @@ -155,89 +156,28 @@ void PublishPort::Validate() const // Source: https://github.com/moby/moby/blob/master/volume/validate.go bool VolumeMount::IsValidNamedVolumeName(const std::wstring& name) { - static const std::wregex namedVolumeRegex(LR"(^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,}$)"); - return std::regex_match(name, namedVolumeRegex); + return mount::IsValidNamedVolumeName(name); } VolumeMount VolumeMount::Parse(const std::wstring& value) { - auto lastColon = value.rfind(':'); - if (lastColon == std::wstring::npos) + mount::Spec mountSpec; + try { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_VolumeInvalidSpec(value, Localization::WSLCCLI_VolumeFormatUsage())); + mountSpec = mount::ParseDockerVolumeString(value); + mount::ValidateMountSpec(mountSpec); } - - VolumeMount vm; - auto splitColon = lastColon; - const auto lastToken = value.substr(lastColon + 1); - if (IsValidMode(lastToken)) - { - vm.m_isReadOnlyMode = IsReadOnlyMode(lastToken); - if (lastColon == 0) - { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_VolumeInvalidSpec(value, Localization::WSLCCLI_VolumeFormatUsage())); - } - - splitColon = value.rfind(':', lastColon - 1); - if (splitColon == std::wstring::npos) - { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_VolumeInvalidSpec(value, Localization::WSLCCLI_VolumeFormatUsage())); - } - - vm.m_containerPath = WideToMultiByte(value.substr(splitColon + 1, lastColon - splitColon - 1)); - } - else - { - vm.m_containerPath = WideToMultiByte(lastToken); - } - - if (vm.m_containerPath.empty()) - { - THROW_HR_WITH_USER_ERROR( - E_INVALIDARG, Localization::WSLCCLI_VolumeContainerPathEmpty(value, Localization::WSLCCLI_VolumeFormatUsage())); - } - - if (vm.m_containerPath[0] != '/') - { - THROW_HR_WITH_USER_ERROR( - E_INVALIDARG, Localization::WSLCCLI_VolumeContainerPathNotAbsolute(value, Localization::WSLCCLI_VolumeFormatUsage())); - } - - const auto rawHostPath = value.substr(0, splitColon); - if (rawHostPath.empty()) + catch (const mount::MountException& ex) { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_VolumeHostPathEmpty(value, Localization::WSLCCLI_VolumeFormatUsage())); + THROW_HR_WITH_USER_ERROR(E_INVALIDARG, ex.Reason()); } - // This is where we need to check if the user is referencing a named volume. - // This can be either an existing named volume or a new named volume that will be created. - if (VolumeMount::IsValidNamedVolumeName(rawHostPath)) - { - vm.m_isNamedVolume = true; - vm.m_host = rawHostPath; - } - else - { - // Not a named volume, so it must be a path. - // Use wil::GetFullPathNameW to resolve relative paths against the CWD. - std::wstring resolvedHostPath; - const auto hr = wil::GetFullPathNameW(rawHostPath.c_str(), resolvedHostPath); - if (FAILED(hr)) - { - THROW_HR_WITH_USER_ERROR(hr, Localization::WSLCCLI_VolumeHostPathInvalid(value, rawHostPath)); - } - - // GetFileAttributesW validates the resolved path syntax without requiring existence. - // ERROR_INVALID_NAME indicates illegal characters in the path (e.g. ":" as a component). - if (GetFileAttributesW(resolvedHostPath.c_str()) == INVALID_FILE_ATTRIBUTES && GetLastError() == ERROR_INVALID_NAME) - { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_VolumeHostPathInvalid(value, rawHostPath)); - } - - vm.m_host = std::move(resolvedHostPath); - } - - return vm; + VolumeMount volume; + volume.m_host = std::move(mountSpec.Source); + volume.m_containerPath = std::move(mountSpec.Target); + volume.m_isReadOnlyMode = mountSpec.ReadOnly; + volume.m_isNamedVolume = mountSpec.MountType == mount::Type::Volume; + return volume; } std::optional EnvironmentVariable::Parse(const std::wstring& entry) @@ -318,19 +258,21 @@ std::vector EnvironmentVariable::ParseFile(const std::wstring& fil return envVars; } -TmpfsMount TmpfsMount::Parse(const std::string& value) +void ValidateUniqueMountDestinations(const ContainerOptions& options) { - TmpfsMount result{}; - auto colonPos = value.find(':'); - if (colonPos == std::string::npos) + try { - result.m_containerPath = value; - return result; + mount::ValidateMountCollection(options.Mounts); } + catch (const mount::MountException& ex) + { + if (ex.Error() == mount::ValidationError::DuplicateDestination) + { + THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_DuplicateMountDestinationError(MultiByteToWide(ex.Destination()))); + } - result.m_containerPath = value.substr(0, colonPos); - result.m_options = value.substr(colonPos + 1); - return result; + throw; + } } CidFile::CidFile(const std::optional& path) diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index 3db13fde4..964ea2f19 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -14,6 +14,7 @@ Module Name: #pragma once +#include "MountSpecParsing.h" #include #include #include @@ -22,6 +23,8 @@ Module Name: namespace wsl::windows::wslc::models { +namespace mount = wsl::windows::common::mount; + // Valid formats for container list output. enum class FormatType { @@ -73,7 +76,7 @@ struct ContainerOptions bool NoHealthcheck = false; bool Gpu = false; std::vector Ports; - std::vector Volumes; + std::vector Mounts; std::string WorkingDirectory; std::vector Entrypoint; std::optional User{}; @@ -84,7 +87,6 @@ struct ContainerOptions std::vector DnsOptions; std::vector Networks; std::vector NetworkAliases; - std::vector Tmpfs; std::vector> Labels; std::optional CidFile{}; std::optional MemoryBytes{}; @@ -305,34 +307,9 @@ struct VolumeMount std::string m_containerPath; bool m_isReadOnlyMode = false; bool m_isNamedVolume = false; - - static bool IsReadOnlyMode(const std::wstring& mode) - { - return mode == L"ro"; - } - - static bool IsValidMode(const std::wstring& mode) - { - return IsReadOnlyMode(mode) || mode == L"rw"; - } }; -struct TmpfsMount -{ - std::string ContainerPath() const - { - return m_containerPath; - } - std::string Options() const - { - return m_options; - } - static TmpfsMount Parse(const std::string& value); - -private: - std::string m_containerPath; - std::string m_options; -}; +void ValidateUniqueMountDestinations(const ContainerOptions& options); class CidFile { diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index d59ce98dc..bcdc969c9 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -29,6 +29,8 @@ Module Name: #include namespace wsl::windows::wslc::services { +namespace mount = wsl::windows::common::mount; + using wsl::windows::common::ClientRunningWSLCProcess; using wsl::windows::common::wslc_schema::InspectContainer; using namespace wsl::windows::common::wslutil; @@ -148,20 +150,9 @@ static wsl::windows::common::RunningWSLCContainer CreateInternal(Terminal& termi } } - // Add volumes if specified - for (const auto& volumeSpec : options.Volumes) + for (const auto& mountSpec : options.Mounts) { - auto volume = VolumeMount::Parse(volumeSpec); - auto host = volume.Host(); - auto container = volume.ContainerPath(); - if (volume.IsNamedVolume()) - { - containerLauncher.AddNamedVolume(string::WideToMultiByte(host), container, volume.IsReadOnly()); - } - else - { - containerLauncher.AddVolume(host, container, volume.IsReadOnly()); - } + containerLauncher.AddMount(mountSpec); } containerLauncher.SetContainerFlags(containerFlags); @@ -268,12 +259,6 @@ static wsl::windows::common::RunningWSLCContainer CreateInternal(Terminal& termi containerLauncher.SetDnsOptions(std::vector(options.DnsOptions)); } - for (const auto& tmpfsSpec : options.Tmpfs) - { - auto tmpfsMount = TmpfsMount::Parse(tmpfsSpec); - containerLauncher.AddTmpfs(tmpfsMount.ContainerPath(), tmpfsMount.Options()); - } - for (const auto& [key, value] : options.Labels) { containerLauncher.AddLabel(key, value); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index 0cf57fdda..cb5b1cde5 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -19,6 +19,7 @@ Module Name: #include "ContainerService.h" #include "ContainerTasks.h" #include "ImageModel.h" +#include "MountSpecParsing.h" #include "SessionModel.h" #include "SessionService.h" #include "TableOutput.h" @@ -658,11 +659,13 @@ void SetContainerOptionsFromArgs(CLIExecutionContext& context) if (context.Args.Contains(ArgType::Volume)) { auto volumes = context.Args.GetAllValues(); - options.Volumes.reserve(options.Volumes.size() + volumes.size()); - for (const auto& volume : volumes) - { - options.Volumes.emplace_back(volume); - } + options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(volumes.begin()), std::make_move_iterator(volumes.end())); + } + + if (context.Args.Contains(ArgType::Mount)) + { + auto mounts = context.Args.GetAllValues(); + options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(mounts.begin()), std::make_move_iterator(mounts.end())); } options.Remove = context.Args.GetValue(); @@ -827,13 +830,11 @@ void SetContainerOptionsFromArgs(CLIExecutionContext& context) if (context.Args.Contains(ArgType::TMPFS)) { auto tmpfs = context.Args.GetAllValues(); - options.Tmpfs.reserve(options.Tmpfs.size() + tmpfs.size()); - for (const auto& value : tmpfs) - { - options.Tmpfs.emplace_back(WideToMultiByte(value)); - } + options.Mounts.insert(options.Mounts.end(), std::make_move_iterator(tmpfs.begin()), std::make_move_iterator(tmpfs.end())); } + ValidateUniqueMountDestinations(options); + for (const auto& label : context.Args.GetAllValues()) { options.Labels.push_back(label); diff --git a/src/windows/wslcsession/WSLCContainer.cpp b/src/windows/wslcsession/WSLCContainer.cpp index 7f60402df..5bc4ce583 100644 --- a/src/windows/wslcsession/WSLCContainer.cpp +++ b/src/windows/wslcsession/WSLCContainer.cpp @@ -24,6 +24,8 @@ Module Name: #include "WSLCProcessIO.h" #include "WSLCVolumes.h" #include "APICompat.h" +#include "MountSpecParsing.h" +#include namespace apicompat = wsl::windows::common::apicompat; @@ -424,9 +426,21 @@ auto MountVolumes(std::vector& volumes, WSLCVirtualMachine& par for (auto& volume : volumes) { - // Create a new directory if it doesn't exist. - if (!std::filesystem::exists(volume.HostPath)) + std::error_code error; + const auto sourceExists = std::filesystem::exists(volume.HostPath, error); + if (error) { + throw wsl::windows::common::mount::MountValidationException( + Localization::MessageWslcBindSourcePathError(volume.HostPath, error.message())); + } + + if (!sourceExists) + { + if (!volume.CreateSourceIfMissing) + { + throw wsl::windows::common::mount::MountValidationException(Localization::MessageWslcBindSourcePathNotFound(volume.HostPath)); + } + auto result = wil::CreateDirectoryDeepNoThrow(volume.HostPath.c_str()); if (FAILED(result)) { @@ -443,6 +457,18 @@ auto MountVolumes(std::vector& volumes, WSLCVirtualMachine& par return std::move(errorCleanup); } +auto MountVolumesWithUserError(std::vector& volumes, WSLCVirtualMachine& parentVM) +{ + try + { + return MountVolumes(volumes, parentVM); + } + catch (const wsl::windows::common::mount::MountValidationException& ex) + { + THROW_HR_WITH_USER_ERROR(E_INVALIDARG, ex.Reason()); + } +} + WSLCContainerState DockerStateToWSLCState(ContainerState state) { // TODO: Handle other states like Paused, Restarting, etc. @@ -531,6 +557,210 @@ std::map StripInternalLabels(std::optional{})); } +std::vector ConvertAndValidateMounts(const WSLCContainerOptions& containerOptions) +{ + namespace mount = wsl::windows::common::mount; + + THROW_HR_IF(E_INVALIDARG, containerOptions.MountsCount > 0 && containerOptions.Mounts == nullptr); + + std::vector mounts; + mounts.reserve(containerOptions.MountsCount); + for (ULONG i = 0; i < containerOptions.MountsCount; ++i) + { + const auto& value = containerOptions.Mounts[i]; + THROW_HR_IF_NULL_MSG(E_INVALIDARG, value.Target, "Mount at index %lu has null Target", i); + THROW_HR_IF_MSG( + E_INVALIDARG, + WI_IsAnyFlagSet(value.Flags, ~WSLCMountSpecFlagsValid), + "Mount at index %lu has invalid flags: 0x%x", + i, + value.Flags); + + mount::Type type; + switch (value.Type) + { + case WSLCMountTypeBind: + type = mount::Type::Bind; + break; + + case WSLCMountTypeVolume: + type = mount::Type::Volume; + break; + + case WSLCMountTypeTmpfs: + type = mount::Type::Tmpfs; + break; + + default: + THROW_HR_MSG(E_INVALIDARG, "Mount at index %lu has invalid type: %d", i, value.Type); + } + + THROW_HR_IF_MSG( + E_INVALIDARG, + type != mount::Type::Bind && WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsCreateSourceIfMissing), + "Mount at index %lu specifies create-source-if-missing for a non-bind mount", + i); + THROW_HR_IF_MSG( + E_INVALIDARG, + type != mount::Type::Tmpfs && WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsOptions), + "Mount at index %lu specifies tmpfs options for a non-tmpfs mount", + i); + THROW_HR_IF_MSG( + E_INVALIDARG, + WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsOptions) && + WI_IsAnyFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsSize | WSLCMountSpecFlagsTmpfsMode), + "Mount at index %lu combines legacy and structured tmpfs options", + i); + + mounts.push_back({ + .MountType = type, + .Source = value.Source != nullptr ? value.Source : L"", + .Target = value.Target, + .ReadOnly = static_cast(value.ReadOnly), + .BindSource = WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsCreateSourceIfMissing) ? mount::BindSourcePolicy::CreateIfMissing + : mount::BindSourcePolicy::RequireExisting, + .TmpfsSizeBytes = WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsSize) ? std::optional{value.TmpfsSizeBytes} : std::nullopt, + .TmpfsMode = WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsMode) ? std::optional{value.TmpfsMode} : std::nullopt, + .TmpfsOptions = WI_IsFlagSet(value.Flags, WSLCMountSpecFlagsTmpfsOptions) + ? std::optional{value.TmpfsOptions != nullptr ? value.TmpfsOptions : ""} + : std::nullopt, + }); + } + + try + { + mount::ValidateMountCollection(mounts); + for (const auto& mount : mounts) + { + if (mount.MountType == mount::Type::Bind) + { + if (mount.BindSource == mount::BindSourcePolicy::CreateIfMissing) + { + continue; + } + + std::error_code error; + const auto sourceExists = std::filesystem::exists(mount.Source, error); + if (error) + { + throw mount::MountValidationException(Localization::MessageWslcBindSourcePathError(mount.Source, error.message())); + } + + if (!sourceExists) + { + throw mount::MountValidationException(Localization::MessageWslcBindSourcePathNotFound(mount.Source)); + } + } + } + } + catch (const mount::MountException& ex) + { + if (ex.Error() == mount::ValidationError::DuplicateDestination) + { + THROW_HR_WITH_USER_ERROR( + E_INVALIDARG, Localization::WSLCCLI_DuplicateMountDestinationError(wsl::shared::string::MultiByteToWide(ex.Destination()))); + } + + THROW_HR_WITH_USER_ERROR(E_INVALIDARG, ex.Reason()); + } + + std::unordered_set destinations; + const auto addDestination = [&](const char* destination) { + THROW_HR_IF_NULL(E_INVALIDARG, destination); + + const auto normalizedDestination = mount::NormalizeDestination(destination); + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, + Localization::WSLCCLI_DuplicateMountDestinationError(wsl::shared::string::MultiByteToWide(normalizedDestination)), + !destinations.emplace(normalizedDestination).second); + }; + + for (const auto& mount : mounts) + { + addDestination(mount.Target.c_str()); + } + + THROW_HR_IF(E_INVALIDARG, containerOptions.VolumesCount > 0 && containerOptions.Volumes == nullptr); + for (ULONG i = 0; i < containerOptions.VolumesCount; ++i) + { + THROW_HR_IF_NULL_MSG(E_INVALIDARG, containerOptions.Volumes[i].HostPath, "Volumes[%lu].HostPath is null", i); + addDestination(containerOptions.Volumes[i].ContainerPath); + } + + THROW_HR_IF(E_INVALIDARG, containerOptions.NamedVolumesCount > 0 && containerOptions.NamedVolumes == nullptr); + for (ULONG i = 0; i < containerOptions.NamedVolumesCount; ++i) + { + THROW_HR_IF_NULL_MSG(E_INVALIDARG, containerOptions.NamedVolumes[i].Name, "NamedVolume at index %lu has null Name", i); + addDestination(containerOptions.NamedVolumes[i].ContainerPath); + } + + THROW_HR_IF(E_INVALIDARG, containerOptions.TmpfsCount > 0 && containerOptions.Tmpfs == nullptr); + for (ULONG i = 0; i < containerOptions.TmpfsCount; ++i) + { + addDestination(containerOptions.Tmpfs[i].Destination); + } + + return mounts; +} + +struct PreparedBindMount +{ + WSLCVolumeMount Volume; + std::string DockerSource; +}; + +enum class MissingBindSource +{ + Create, + Reject, +}; + +PreparedBindMount PrepareBindMount(const std::wstring& source, const std::string& target, bool readOnly, MissingBindSource missingSource) +{ + GUID volumeId; + THROW_IF_FAILED(CoCreateGuid(&volumeId)); + + auto parentVMPath = std::format("/mnt/{}", wsl::shared::string::GuidToString(volumeId)); + std::filesystem::path hostPath = source; + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessagePathNotAbsolute(source), !hostPath.is_absolute()); + + std::wstring sourceFilename; + { + std::error_code ec; + hostPath = std::filesystem::canonical(hostPath, ec); + if (!ec) + { + if (std::filesystem::is_regular_file(hostPath)) + { + sourceFilename = hostPath.filename().wstring(); + hostPath = hostPath.parent_path(); + } + } + else if (ec == std::errc::no_such_file_or_directory) + { + hostPath = source; + } + else + { + THROW_HR_WITH_USER_ERROR(E_FAIL, Localization::MessageWslcFailedToMountVolume(source, ec.message())); + } + } + + auto dockerSource = sourceFilename.empty() ? parentVMPath : std::format("{}/{}", parentVMPath, sourceFilename); + return { + .Volume = + { + .HostPath = std::move(hostPath), + .ParentVMPath = std::move(parentVMPath), + .ContainerPath = target, + .ReadOnly = readOnly, + .SourceFilename = std::move(sourceFilename), + .CreateSourceIfMissing = missingSource == MissingBindSource::Create, + }, + .DockerSource = std::move(dockerSource), + }; +} + void ProcessNamedVolumes(const WSLCContainerOptions& containerOptions, wsl::windows::common::docker_schema::CreateContainer& request) { THROW_HR_IF(E_INVALIDARG, containerOptions.NamedVolumesCount > 0 && containerOptions.NamedVolumes == nullptr); @@ -910,7 +1140,7 @@ void WSLCContainerImpl::Start(WSLCContainerStartFlags Flags, const WSLCProcessSt Localization::MessageWslcVolumeNotAvailable(wsl::shared::string::Join(unavailableVolumes, ',')), !unavailableVolumes.empty()); - auto volumeCleanup = MountVolumes(m_mountedVolumes, m_runtime.Vm()); + auto volumeCleanup = MountVolumesWithUserError(m_mountedVolumes, m_runtime.Vm()); auto portCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [this]() { UnmapPorts(); }); MapPorts(); @@ -1611,7 +1841,9 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec } // Map mounts without exposing Linux paths from the utility VM. - wslcInspect.Mounts.reserve(m_mountedVolumes.size() + dockerInspect.Mounts.size() + dockerInspect.HostConfig.Tmpfs.size()); + wslcInspect.Mounts.reserve( + m_mountedVolumes.size() + dockerInspect.Mounts.size() + dockerInspect.HostConfig.Tmpfs.size() + + dockerInspect.HostConfig.Mounts.size()); for (const auto& volume : m_mountedVolumes) { wslc_schema::InspectMount mountInfo{}; @@ -1650,6 +1882,13 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslc_schema::InspectMount mountInfo{}; mountInfo.Type = volume.Type; mountInfo.Name = volume.Name; + const auto structuredMount = std::ranges::find_if(dockerInspect.HostConfig.Mounts, [&](const auto& mount) { + return mount.Type == "volume" && mount.Target == volume.Destination; + }); + if (structuredMount != dockerInspect.HostConfig.Mounts.end()) + { + mountInfo.Source = structuredMount->Source; + } mountInfo.Destination = volume.Destination; mountInfo.ReadWrite = volume.RW; @@ -1668,6 +1907,20 @@ WslcInspectContainer WSLCContainerImpl::BuildInspectContainer(const DockerInspec wslcInspect.Mounts.push_back(std::move(mountInfo)); } + // Bind mounts are populated from m_mountedVolumes so their inspect source is the Windows host path. + for (const auto& mount : dockerInspect.HostConfig.Mounts) + { + if (mount.Type == "tmpfs") + { + wslc_schema::InspectMount mountInfo{}; + mountInfo.Type = mount.Type; + mountInfo.Source = mount.Source; + mountInfo.Destination = mount.Target; + mountInfo.ReadWrite = !mount.ReadOnly; + wslcInspect.Mounts.push_back(std::move(mountInfo)); + } + } + // Config.Labels is the Docker-shape location; top-level Labels is a legacy alias. wslcInspect.Config.Labels = m_labels; wslcInspect.Labels = m_labels; @@ -1708,6 +1961,7 @@ std::shared_ptr WSLCContainerImpl::Create( auto& virtualMachine = runtime.Vm(); auto& DockerClient = runtime.Docker(); auto& EventTracker = runtime.Events(); + const auto mounts = ConvertAndValidateMounts(containerOptions); common::docker_schema::CreateContainer request; request.Image = containerOptions.Image; @@ -1869,73 +2123,22 @@ std::shared_ptr WSLCContainerImpl::Create( request.Healthcheck = std::move(health); } - if (containerOptions.VolumesCount > 0) - { - THROW_HR_IF_NULL_MSG(E_INVALIDARG, containerOptions.Volumes, "Volumes is null with VolumesCount=%lu", containerOptions.VolumesCount); - } - // Build bind mount list from container options. std::vector volumes; - volumes.reserve(containerOptions.VolumesCount); + volumes.reserve(containerOptions.VolumesCount + mounts.size()); std::vector binds; binds.reserve(containerOptions.VolumesCount); for (ULONG i = 0; i < containerOptions.VolumesCount; i++) { - GUID volumeId; - THROW_IF_FAILED(CoCreateGuid(&volumeId)); - - auto parentVMPath = std::format("/mnt/{}", wsl::shared::string::GuidToString(volumeId)); auto volume = containerOptions.Volumes[i]; - - THROW_HR_IF_NULL_MSG(E_INVALIDARG, volume.HostPath, "Volumes[%lu].HostPath is null", i); - THROW_HR_IF_NULL_MSG(E_INVALIDARG, volume.ContainerPath, "Volumes[%lu].ContainerPath is null", i); - - std::filesystem::path hostPath = volume.HostPath; - THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessagePathNotAbsolute(volume.HostPath), !hostPath.is_absolute()); - - std::wstring sourceFilename; - - { - // Resolve symlinks. - std::error_code ec; - hostPath = std::filesystem::canonical(hostPath, ec); - if (!ec) - { - // When the host path is a file, mount the parent directory in the VM - // and bind only the specific file into the container via Docker. - if (std::filesystem::is_regular_file(hostPath)) - { - sourceFilename = hostPath.filename().wstring(); - hostPath = hostPath.parent_path(); - } - } - else - { - if (ec == std::errc::no_such_file_or_directory) - { - // Path doesn't exist, assume directory. - hostPath = volume.HostPath; - } - else - { - THROW_HR_WITH_USER_ERROR(E_FAIL, Localization::MessageWslcFailedToMountVolume(volume.HostPath, ec.message())); - } - } - } - - volumes.push_back(WSLCVolumeMount{hostPath, parentVMPath, volume.ContainerPath, static_cast(volume.ReadOnly), sourceFilename}); - - auto options = volume.ReadOnly ? "ro" : "rw"; - auto bindSource = sourceFilename.empty() ? parentVMPath : std::format("{}/{}", parentVMPath, sourceFilename); - auto bind = std::format("{}:{}:{}", bindSource, volume.ContainerPath, options); - - binds.push_back(std::move(bind)); + auto prepared = + PrepareBindMount(volume.HostPath, volume.ContainerPath, static_cast(volume.ReadOnly), MissingBindSource::Create); + binds.push_back(std::format("{}:{}:{}", prepared.DockerSource, volume.ContainerPath, volume.ReadOnly ? "ro" : "rw")); + volumes.push_back(std::move(prepared.Volume)); } - request.HostConfig.Binds = std::move(binds); - // Process tmpfs mounts from container options. if (containerOptions.TmpfsCount > 0) { @@ -1953,6 +2156,56 @@ std::shared_ptr WSLCContainerImpl::Create( ProcessNamedVolumes(containerOptions, request); + for (const auto& mount : mounts) + { + common::docker_schema::Mount dockerMount{ + .Target = mount.Target, + .ReadOnly = mount.ReadOnly, + }; + + switch (mount.MountType) + { + case wsl::windows::common::mount::Type::Bind: + { + // Docker's colon-delimited bind format cannot represent ':' in the target. + const auto missingSource = mount.BindSource == wsl::windows::common::mount::BindSourcePolicy::CreateIfMissing + ? MissingBindSource::Create + : MissingBindSource::Reject; + auto prepared = PrepareBindMount(mount.Source, mount.Target, mount.ReadOnly, missingSource); + dockerMount.Source = std::move(prepared.DockerSource); + dockerMount.Type = "bind"; + volumes.push_back(std::move(prepared.Volume)); + break; + } + + case wsl::windows::common::mount::Type::Volume: + dockerMount.Source = wsl::shared::string::WideToMultiByte(mount.Source); + dockerMount.Type = "volume"; + break; + + case wsl::windows::common::mount::Type::Tmpfs: + if (mount.TmpfsOptions.has_value()) + { + request.HostConfig.Tmpfs[mount.Target] = mount.TmpfsOptions.value(); + continue; + } + + dockerMount.Type = "tmpfs"; + if (mount.TmpfsSizeBytes.has_value() || mount.TmpfsMode.has_value()) + { + dockerMount.TmpfsOptions = common::docker_schema::MountTmpfsOptions{ + .SizeBytes = mount.TmpfsSizeBytes.value_or(0), + .Mode = mount.TmpfsMode.value_or(0), + }; + } + break; + } + + request.HostConfig.Mounts.push_back(std::move(dockerMount)); + } + + request.HostConfig.Binds = std::move(binds); + // Configure GPU support if requested. if (WI_IsFlagSet(containerOptions.Flags, WSLCContainerFlagsGpu)) { @@ -2090,8 +2343,12 @@ std::shared_ptr WSLCContainerImpl::Create( request.Labels[WSLCContainerMetadataLabel] = SerializeContainerMetadata(metadata); request.Labels.insert(requestedLabels.begin(), requestedLabels.end()); - // Send the request to docker. - auto result = DockerClient.CreateContainer(request, containerName); + // Docker validates structured bind sources during container creation, so their VM paths must exist here. + // Release the temporary shares before returning; Start remounts them for the container lifetime. + auto result = [&]() { + auto volumeCleanup = MountVolumesWithUserError(volumes, virtualMachine); + return DockerClient.CreateContainer(request, containerName); + }(); // Surface any warnings returned by Docker (e.g., deprecated features, configuration issues). for (const auto& warning : result.Warnings) @@ -2145,12 +2402,20 @@ std::shared_ptr WSLCContainerImpl::Create( // Collect the names of referenced docker named volumes so Start() can verify // they are available before running the container. std::vector namedVolumes; - namedVolumes.reserve(containerOptions.NamedVolumesCount); + namedVolumes.reserve(containerOptions.NamedVolumesCount + mounts.size()); for (ULONG i = 0; i < containerOptions.NamedVolumesCount; i++) { namedVolumes.emplace_back(containerOptions.NamedVolumes[i].Name); } + for (const auto& mount : mounts) + { + if (mount.MountType == wsl::windows::common::mount::Type::Volume && !mount.Source.empty()) + { + namedVolumes.emplace_back(wsl::shared::string::WideToMultiByte(mount.Source)); + } + } + auto mergedLabels = StripInternalLabels(std::move(inspectData.Config.Labels)); auto container = std::make_shared( diff --git a/src/windows/wslcsession/WSLCContainerMetadata.h b/src/windows/wslcsession/WSLCContainerMetadata.h index 0d19c88ad..a81a46526 100644 --- a/src/windows/wslcsession/WSLCContainerMetadata.h +++ b/src/windows/wslcsession/WSLCContainerMetadata.h @@ -44,11 +44,12 @@ struct WSLCVolumeMount // Non-empty when the mount target is a single file rather than a directory. std::wstring SourceFilename; + bool CreateSourceIfMissing{true}; // Runtime-only field. Not serialized to JSON. bool Mounted{}; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(WSLCVolumeMount, HostPath, ParentVMPath, ContainerPath, ReadOnly, SourceFilename); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(WSLCVolumeMount, HostPath, ParentVMPath, ContainerPath, ReadOnly, SourceFilename, CreateSourceIfMissing); }; struct WSLCContainerMetadataV1 diff --git a/test/windows/wslc/WSLCCLIMountParserUnitTests.cpp b/test/windows/wslc/WSLCCLIMountParserUnitTests.cpp new file mode 100644 index 000000000..d9667a915 --- /dev/null +++ b/test/windows/wslc/WSLCCLIMountParserUnitTests.cpp @@ -0,0 +1,548 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCCLIMountParserUnitTests.cpp + +Abstract: + + Unit tests for Docker-compatible --mount parsing. + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCCLITestHelpers.h" +#include "ContainerModel.h" +#include "MountSpecParsing.h" + +using namespace wsl::windows::common; +using namespace wsl::windows::wslc::models; +using namespace wsl::shared; +using namespace WEX::Logging; +using namespace WEX::Common; + +namespace WSLCCLIMountParserUnitTests { + +namespace { + + mount::Spec ParseAndValidate(const std::wstring& value) + { + auto mountSpec = mount::ParseDockerMountString(value); + mount::ValidateMountSpec(mountSpec); + return mountSpec; + } + + struct ValidMountCase + { + const wchar_t* Input; + mount::Type Type; + const wchar_t* Source; + const char* Target; + bool ReadOnly; + std::optional TmpfsSizeBytes; + std::optional TmpfsMode; + const char* TmpfsOptions; + }; + + enum class ExpectedException + { + Parse, + Unsupported, + Validation, + }; + + struct InvalidMountCase + { + const wchar_t* Input; + std::wstring ExpectedReason; + ExpectedException Exception = ExpectedException::Parse; + }; + + constexpr ValidMountCase c_validMountCases[] = { + {L"type=volume,target=/data", mount::Type::Volume, L"", "/data", false, {}, {}, ""}, + {L"source=data-volume,target=/data", mount::Type::Volume, L"data-volume", "/data", false, {}, {}, ""}, + {L"type=volume,source=data-volume,target=/path:voldir", + mount::Type::Volume, + L"data-volume", + "/path:voldir", + false, + {}, + {}, + ""}, + {L"TYPE=VOLUME,SOURCE=data-volume,TARGET=/data", mount::Type::Volume, L"data-volume", "/data", false, {}, {}, ""}, + {L"type=VoLuMe,source=data-volume,target=/data", mount::Type::Volume, L"data-volume", "/data", false, {}, {}, ""}, + {L"type=volume,src=data-volume,dst=/data", mount::Type::Volume, L"data-volume", "/data", false, {}, {}, ""}, + {L"type=volume,src=data-volume,destination=/data", mount::Type::Volume, L"data-volume", "/data", false, {}, {}, ""}, + {L"type=volume,source=first,source=second,target=/data", mount::Type::Volume, L"second", "/data", false, {}, {}, ""}, + {L"type=volume,source=data-volume,target=/first,target=/second", + mount::Type::Volume, + L"data-volume", + "/second", + false, + {}, + {}, + ""}, + {L"type=volume,type=bind,source=C:\\data,target=/data", mount::Type::Bind, L"C:\\data", "/data", false, {}, {}, ""}, + {L"type=volume,source=data-volume,target=/data,readonly", mount::Type::Volume, L"data-volume", "/data", true, {}, {}, ""}, + {L"type=volume,source=data-volume,target=/data,ro", mount::Type::Volume, L"data-volume", "/data", true, {}, {}, ""}, + {L"type=volume,source=data-volume,target=/data,readonly=1", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=t", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=T", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=TRUE", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=true", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=True", + mount::Type::Volume, + L"data-volume", + "/data", + true, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=0", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=f", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=F", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=FALSE", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=false", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=False", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=data-volume,target=/data,readonly=true,readonly=false", + mount::Type::Volume, + L"data-volume", + "/data", + false, + {}, + {}, + ""}, + {L"type=bind,\"source=C:\\mount,a\",target=/data", mount::Type::Bind, L"C:\\mount,a", "/data", false, {}, {}, ""}, + {L"type=bind,source=C:\\mount with spaces,target=/data", + mount::Type::Bind, + L"C:\\mount with spaces", + "/data", + false, + {}, + {}, + ""}, + {L"type=bind,source=C:\\mount,target=/path:mntdir", mount::Type::Bind, L"C:\\mount", "/path:mntdir", false, {}, {}, ""}, + {L"type=bind,source=C:\\,target=/data", mount::Type::Bind, L"C:\\", "/data", false, {}, {}, ""}, + {L"type=bind,source=\\\\server\\share,target=/data", mount::Type::Bind, L"\\\\server\\share", "/data", false, {}, {}, ""}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=enabled", + mount::Type::Bind, + L"C:\\mount", + "/data", + false, + {}, + {}, + ""}, + {L"type=volume,source=A_,target=/data", mount::Type::Volume, L"A_", "/data", false, {}, {}, ""}, + {L"type=volume,source=data.volume-1,target=/data", mount::Type::Volume, L"data.volume-1", "/data", false, {}, {}, ""}, + {L"type=tmpfs,target=/tmp", mount::Type::Tmpfs, L"", "/tmp", false, {}, {}, ""}, + {L"type=tmpfs,target=/path:tmpfs", mount::Type::Tmpfs, L"", "/path:tmpfs", false, {}, {}, ""}, + {L"type=tmpfs,target=/tmp,readonly", mount::Type::Tmpfs, L"", "/tmp", true, {}, {}, "ro"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=0", mount::Type::Tmpfs, L"", "/tmp", false, 0, {}, ""}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1", mount::Type::Tmpfs, L"", "/tmp", false, 1, {}, "size=1"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1024", mount::Type::Tmpfs, L"", "/tmp", false, 1024, {}, "size=1k"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1536", mount::Type::Tmpfs, L"", "/tmp", false, 1536, {}, "size=1536"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1k", mount::Type::Tmpfs, L"", "/tmp", false, 1024, {}, "size=1k"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1KB", mount::Type::Tmpfs, L"", "/tmp", false, 1024, {}, "size=1k"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1KiB", mount::Type::Tmpfs, L"", "/tmp", false, 1024, {}, "size=1k"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1MB", mount::Type::Tmpfs, L"", "/tmp", false, 1LL << 20, {}, "size=1m"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1MiB", mount::Type::Tmpfs, L"", "/tmp", false, 1LL << 20, {}, "size=1m"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1GB", mount::Type::Tmpfs, L"", "/tmp", false, 1LL << 30, {}, "size=1g"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1.5MB", mount::Type::Tmpfs, L"", "/tmp", false, 1536LL << 10, {}, "size=1536k"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=+1MB", mount::Type::Tmpfs, L"", "/tmp", false, 1LL << 20, {}, "size=1m"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1e3", mount::Type::Tmpfs, L"", "/tmp", false, 1000, {}, "size=1000"}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=0000", mount::Type::Tmpfs, L"", "/tmp", false, {}, 0, ""}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=0700", mount::Type::Tmpfs, L"", "/tmp", false, {}, 0700, "mode=700"}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=+0700", mount::Type::Tmpfs, L"", "/tmp", false, {}, 0700, "mode=700"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1MB,tmpfs-mode=0700,readonly", + mount::Type::Tmpfs, + L"", + "/tmp", + true, + 1LL << 20, + 0700, + "ro,mode=700,size=1m"}, + {L"type=tmpfs,target=/tmp,tmpfs-size=0,tmpfs-mode=0000,readonly=false", mount::Type::Tmpfs, L"", "/tmp", false, 0, 0, ""}, + }; + + const InvalidMountCase c_invalidMountCases[] = { + {L"", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"")}, + {L",", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"")}, + {L"type=volume,source=data-volume,target=/data,", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"")}, + {L",type=volume,source=data-volume,target=/data", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"")}, + {L"type=bind,\"source=C:\\mount,target=/data", Localization::WSLCCLI_MountMalformedCsvError()}, + {L"type=volume,bogus", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"bogus")}, + {L"type=volume,bogus=value", Localization::WSLCCLI_MountUnexpectedKeyError(L"bogus", L"bogus=value")}, + {L"type", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"type")}, + {L"source", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"source")}, + {L"target", Localization::WSLCCLI_MountFieldKeyValueRequiredError(L"target")}, + {L"type=,source=data-volume,target=/data", Localization::WSLCCLI_MountTypeRequiredError()}, + {L"type=volume,source=data-volume", Localization::WSLCCLI_MountTargetRequiredError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,target=", Localization::WSLCCLI_MountTargetRequiredError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,dst=", Localization::WSLCCLI_MountTargetRequiredError(), ExpectedException::Validation}, + {L"type=cluster,source=data-volume,target=/data", Localization::WSLCCLI_MountTypeUnsupportedError(L"cluster"), ExpectedException::Unsupported}, + {L"type=npipe,source=data-volume,target=/data", Localization::WSLCCLI_MountTypeUnsupportedError(L"npipe"), ExpectedException::Unsupported}, + {L"type=bogus,source=data-volume,target=/data", Localization::WSLCCLI_MountTypeUnsupportedError(L"bogus"), ExpectedException::Unsupported}, + {L"type=CLUSTER,source=data-volume,target=/data", Localization::WSLCCLI_MountTypeUnsupportedError(L"cluster"), ExpectedException::Unsupported}, + {L"type=bind,target=/data", Localization::WSLCCLI_MountSourceRequiredError(), ExpectedException::Validation}, + {L"type=bind,source=relative,target=/data", Localization::WSLCCLI_MountBindSourceAbsoluteError(), ExpectedException::Validation}, + {L"type=volume,source=a,target=/data", Localization::WSLCCLI_MountVolumeSourceInvalidError(), ExpectedException::Validation}, + {L"type=volume,source=data/volume,target=/data", Localization::WSLCCLI_MountVolumeSourceInvalidError(), ExpectedException::Validation}, + {L"type=volume,source=C:\\mount,target=/data", Localization::WSLCCLI_MountVolumeSourceInvalidError(), ExpectedException::Validation}, + {L"type=tmpfs,source=data-volume,target=/data", Localization::WSLCCLI_MountTmpfsSourceUnsupportedError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,target=data", Localization::WSLCCLI_MountTargetAbsoluteError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,dst=.", Localization::WSLCCLI_MountTargetAbsoluteError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,destination=\\data", Localization::WSLCCLI_MountTargetAbsoluteError(), ExpectedException::Validation}, + {L"type=bind,source=C:\\mount,target=data", Localization::WSLCCLI_MountTargetAbsoluteError(), ExpectedException::Validation}, + {L"type=tmpfs,target=data", Localization::WSLCCLI_MountTargetAbsoluteError(), ExpectedException::Validation}, + {L"type=volume,source=data-volume,target=/data,readonly=no", + Localization::WSLCCLI_MountInvalidValueError(L"readonly", L"no")}, + {L"type=volume,source=data-volume,target=/data,readonly=yes", + Localization::WSLCCLI_MountInvalidValueError(L"readonly", L"yes")}, + {L"type=volume,source=data-volume,target=/data,readonly=", Localization::WSLCCLI_MountInvalidValueError(L"readonly", L"")}, + {L"type=volume,source=data-volume,target=/data,readonly=2", + Localization::WSLCCLI_MountInvalidValueError(L"readonly", L"2")}, + {L"type=volume,source=data-volume,target=/data,volume-nocopy=no", + Localization::WSLCCLI_MountInvalidValueError(L"volume-nocopy", L"no")}, + {L"type=volume,source=data-volume,target=/data,volume-nocopy=", + Localization::WSLCCLI_MountInvalidValueError(L"volume-nocopy", L"")}, + {L"type=bind,source=C:\\mount,target=/data,bind-nonrecursive=no", + Localization::WSLCCLI_MountInvalidValueError(L"bind-nonrecursive", L"no")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=", + Localization::WSLCCLI_MountInvalidBindRecursiveValueError(L"bind-recursive", L"")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=Enabled", + Localization::WSLCCLI_MountInvalidBindRecursiveValueError(L"bind-recursive", L"Enabled")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=bogus", + Localization::WSLCCLI_MountInvalidBindRecursiveValueError(L"bind-recursive", L"bogus")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=writable", + Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=writable")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=readonly", + Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=readonly")}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=readonly,readonly", + Localization::WSLCCLI_MountBindRecursiveReadonlyRequiresPropagationError()}, + {L"type=bind,source=C:\\mount,target=/data,consistency=cached", + Localization::WSLCCLI_MountOptionUnsupportedError(L"consistency"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-propagation=rprivate", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-propagation"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-nonrecursive", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-nonrecursive"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-nonrecursive=true", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-nonrecursive"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=disabled", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-recursive"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=writable,readonly", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-recursive"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,bind-recursive=readonly,readonly,bind-propagation=rprivate", + Localization::WSLCCLI_MountOptionUnsupportedError(L"bind-recursive"), + ExpectedException::Unsupported}, + {L"type=volume,source=data-volume,target=/data,volume-nocopy", + Localization::WSLCCLI_MountOptionUnsupportedError(L"volume-nocopy"), + ExpectedException::Unsupported}, + {L"type=volume,source=data-volume,target=/data,volume-nocopy=true", + Localization::WSLCCLI_MountOptionUnsupportedError(L"volume-nocopy"), + ExpectedException::Unsupported}, + {L"type=volume,source=data-volume,target=/data,volume-label=a=b", + Localization::WSLCCLI_MountOptionUnsupportedError(L"volume-label"), + ExpectedException::Unsupported}, + {L"type=volume,source=data-volume,target=/data,volume-driver=local", + Localization::WSLCCLI_MountOptionUnsupportedError(L"volume-driver"), + ExpectedException::Unsupported}, + {L"type=volume,source=data-volume,target=/data,volume-opt=a=b", + Localization::WSLCCLI_MountOptionUnsupportedError(L"volume-opt"), + ExpectedException::Unsupported}, + {L"type=bind,source=C:\\mount,target=/data,volume-nocopy=true", + Localization::WSLCCLI_MountOptionFamilyMismatchError(L"volume-*", L"bind")}, + {L"type=volume,source=data-volume,target=/data,bind-propagation=rprivate", + Localization::WSLCCLI_MountOptionFamilyMismatchError(L"bind-*", L"volume")}, + {L"type=volume,source=data-volume,target=/data,bind-recursive=enabled", + Localization::WSLCCLI_MountOptionFamilyMismatchError(L"bind-*", L"volume")}, + {L"type=volume,source=data-volume,target=/data,tmpfs-size=1m", + Localization::WSLCCLI_MountOptionFamilyMismatchError(L"tmpfs-*", L"volume")}, + {L"type=tmpfs,target=/tmp,volume-label=a=b", Localization::WSLCCLI_MountOptionFamilyMismatchError(L"volume-*", L"tmpfs")}, + {L"type=tmpfs,target=/tmp,bind-nonrecursive", Localization::WSLCCLI_MountOptionFamilyMismatchError(L"bind-*", L"tmpfs")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=bad", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"bad")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=-1", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"-1")}, + {L"type=tmpfs,target=/tmp,\"tmpfs-size=1,5MB\"", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"1,5MB")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1XB", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"1XB")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1Ki", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"1Ki")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=1BB", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"1BB")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=nan", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"nan")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=inf", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"inf")}, + {L"type=tmpfs,target=/tmp,tmpfs-size=9223372036854775808", + Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-size", L"9223372036854775808")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=-1", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"-1")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=8", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"8")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=0899", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"0899")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=0x700", Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"0x700")}, + {L"type=tmpfs,target=/tmp,tmpfs-mode=40000000000", + Localization::WSLCCLI_MountInvalidValueError(L"tmpfs-mode", L"40000000000")}, + }; + +} // namespace + +class WSLCCLIMountParserUnitTests +{ + WSLC_TEST_CLASS(WSLCCLIMountParserUnitTests) + + TEST_METHOD(Mount_ValidCases) + { + for (const auto& testCase : c_validMountCases) + { + Log::Comment(String().Format(L"Accepting: %ls", testCase.Input)); + + const auto actual = ParseAndValidate(testCase.Input); + VERIFY_ARE_EQUAL(static_cast(testCase.Type), static_cast(actual.MountType)); + VERIFY_ARE_EQUAL(std::wstring(testCase.Source), actual.Source); + VERIFY_ARE_EQUAL(std::string(testCase.Target), actual.Target); + VERIFY_ARE_EQUAL(testCase.ReadOnly, actual.ReadOnly); + VERIFY_ARE_EQUAL(testCase.TmpfsSizeBytes.has_value(), actual.TmpfsSizeBytes.has_value()); + if (testCase.TmpfsSizeBytes.has_value() && actual.TmpfsSizeBytes.has_value()) + { + VERIFY_ARE_EQUAL(testCase.TmpfsSizeBytes.value(), actual.TmpfsSizeBytes.value()); + } + + VERIFY_ARE_EQUAL(testCase.TmpfsMode.has_value(), actual.TmpfsMode.has_value()); + if (testCase.TmpfsMode.has_value() && actual.TmpfsMode.has_value()) + { + VERIFY_ARE_EQUAL(testCase.TmpfsMode.value(), actual.TmpfsMode.value()); + } + + const auto actualTmpfsOptions = actual.MountType == mount::Type::Tmpfs ? mount::FormatTmpfsOptions(actual) : std::string{}; + VERIFY_ARE_EQUAL(std::string(testCase.TmpfsOptions), actualTmpfsOptions); + } + } + + TEST_METHOD(Mount_InvalidCases) + { + for (const auto& testCase : c_invalidMountCases) + { + Log::Comment(String().Format(L"Rejecting: %ls", testCase.Input)); + + try + { + (void)ParseAndValidate(testCase.Input); + VERIFY_FAIL(L"Expected MountException for invalid mount spec"); + } + catch (const mount::MountException& ex) + { + VERIFY_ARE_EQUAL(testCase.ExpectedReason, ex.Reason()); + switch (testCase.Exception) + { + case ExpectedException::Parse: + VERIFY_IS_TRUE(dynamic_cast(&ex) != nullptr); + break; + + case ExpectedException::Unsupported: + VERIFY_IS_TRUE(dynamic_cast(&ex) != nullptr); + break; + + case ExpectedException::Validation: + VERIFY_IS_TRUE(dynamic_cast(&ex) != nullptr); + break; + } + } + } + } + + TEST_METHOD(Volume_ValidCases) + { + const auto bind = mount::ParseDockerVolumeString(LR"(C:\hostPath:/data:ro)"); + VERIFY_ARE_EQUAL(static_cast(mount::Type::Bind), static_cast(bind.MountType)); + VERIFY_ARE_EQUAL(std::wstring(LR"(C:\hostPath)"), bind.Source); + VERIFY_ARE_EQUAL(std::string("/data"), bind.Target); + VERIFY_IS_TRUE(bind.ReadOnly); + VERIFY_ARE_EQUAL(static_cast(mount::BindSourcePolicy::CreateIfMissing), static_cast(bind.BindSource)); + + const auto volume = mount::ParseDockerVolumeString(L"named-volume:/data"); + VERIFY_ARE_EQUAL(static_cast(mount::Type::Volume), static_cast(volume.MountType)); + VERIFY_ARE_EQUAL(std::wstring(L"named-volume"), volume.Source); + VERIFY_ARE_EQUAL(std::string("/data"), volume.Target); + VERIFY_IS_FALSE(volume.ReadOnly); + VERIFY_ARE_EQUAL(static_cast(mount::BindSourcePolicy::RequireExisting), static_cast(volume.BindSource)); + } + + TEST_METHOD(Mount_DotRelativeBindSourceUsesCurrentDirectory) + { + const auto expected = (std::filesystem::current_path() / L"mount").lexically_normal().wstring(); + const auto actual = ParseAndValidate(L"type=bind,source=.\\mount,target=/data"); + VERIFY_ARE_EQUAL(expected, actual.Source); + } + + TEST_METHOD(Mount_TypedSpecsAreValidated) + { + const mount::Spec relativeBind{ + .MountType = mount::Type::Bind, + .Source = L"relative", + .Target = "/data", + }; + VERIFY_THROWS(mount::ValidateMountSpec(relativeBind), mount::MountValidationException); + + const mount::Spec relativeTarget{ + .MountType = mount::Type::Volume, + .Source = L"data-volume", + .Target = "data", + }; + VERIFY_THROWS(mount::ValidateMountSpec(relativeTarget), mount::MountValidationException); + + const mount::Spec tmpfsWithSource{ + .MountType = mount::Type::Tmpfs, + .Source = L"data-volume", + .Target = "/data", + }; + VERIFY_THROWS(mount::ValidateMountSpec(tmpfsWithSource), mount::MountValidationException); + + const mount::Spec bindWithTmpfsOptions{ + .MountType = mount::Type::Bind, + .Source = L"C:\\data", + .Target = "/data", + .TmpfsSizeBytes = 1024, + }; + VERIFY_THROWS(mount::ValidateMountSpec(bindWithTmpfsOptions), mount::MountValidationException); + + const mount::Spec negativeTmpfsSize{ + .MountType = mount::Type::Tmpfs, + .Target = "/data", + .TmpfsSizeBytes = -1, + }; + VERIFY_THROWS(mount::ValidateMountSpec(negativeTmpfsSize), mount::MountValidationException); + + const mount::Spec tmpfs{ + .MountType = mount::Type::Tmpfs, + .Target = "/data", + .TmpfsSizeBytes = 1024, + .TmpfsMode = 0700, + }; + VERIFY_NO_THROW(mount::ValidateMountSpec(tmpfs)); + + const mount::Spec duplicateMounts[] = { + {.MountType = mount::Type::Tmpfs, .Target = "/data"}, + {.MountType = mount::Type::Volume, .Source = L"data-volume", .Target = "/data/"}, + }; + try + { + mount::ValidateMountCollection(duplicateMounts); + VERIFY_FAIL(L"Expected MountValidationException for duplicate destinations"); + } + catch (const mount::MountValidationException& ex) + { + VERIFY_ARE_EQUAL(static_cast(mount::ValidationError::DuplicateDestination), static_cast(ex.Error())); + VERIFY_ARE_EQUAL(std::string("/data"), ex.Destination()); + } + } + + TEST_METHOD(Mount_DuplicateDestinationsAreRejected) + { + ContainerOptions options; + options.Mounts = { + mount::ParseDockerTmpfsString(L"/data"), + {.MountType = mount::Type::Volume, .Source = L"data-volume", .Target = "/data/"}, + }; + VERIFY_THROWS(ValidateUniqueMountDestinations(options), wil::ResultException); + + options.Mounts = { + {.MountType = mount::Type::Tmpfs, .Target = "/data/../cache"}, + {.MountType = mount::Type::Volume, .Source = L"data-volume", .Target = "/cache"}, + }; + VERIFY_THROWS(ValidateUniqueMountDestinations(options), wil::ResultException); + } + + TEST_METHOD(Mount_UniqueDestinationsAreAccepted) + { + ContainerOptions options; + options.Mounts = { + mount::ParseDockerTmpfsString(L"/cache"), + {.MountType = mount::Type::Volume, .Source = L"data-volume", .Target = "/data"}, + {.MountType = mount::Type::Bind, .Source = L"C:\\logs", .Target = "/logs"}, + }; + VERIFY_NO_THROW(ValidateUniqueMountDestinations(options)); + } +}; + +} // namespace WSLCCLIMountParserUnitTests diff --git a/test/windows/wslc/WSLCCLITmpfsParserUnitTests.cpp b/test/windows/wslc/WSLCCLITmpfsParserUnitTests.cpp index bf96f8374..ddf26b410 100644 --- a/test/windows/wslc/WSLCCLITmpfsParserUnitTests.cpp +++ b/test/windows/wslc/WSLCCLITmpfsParserUnitTests.cpp @@ -13,10 +13,9 @@ Module Name: #include "precomp.h" #include "windows/Common.h" -#include "WSLCCLITestHelpers.h" -#include "ContainerModel.h" +#include "MountSpecParsing.h" -using namespace wsl::windows::wslc; +using namespace wsl::windows::common; namespace WSLCCLITmpfsParserUnitTests { @@ -26,26 +25,40 @@ class WSLCCLITmpfsParserUnitTests TEST_METHOD(WSLCCLITmpfsMount_Parse) { - std::vector> validTmpfsSpecs = { - {"", "", ""}, - {"/tmp", "/tmp", ""}, - {"/tmp:size=50m", "/tmp", "size=50m"}, - {"/var/tmp:size=1g", "/var/tmp", "size=1g"}, - {"/tmp:size=50m,mode=1777", "/tmp", "size=50m,mode=1777"}, - {"/cache:uid=1000,gid=1000", "/cache", "uid=1000,gid=1000"}, - {"/mnt/ramdisk:size=256k,nr_inodes=1k", "/mnt/ramdisk", "size=256k,nr_inodes=1k"}, - {"/securetmp:mode=0700", "/securetmp", "mode=0700"}, - {"/scratch:nosuid,nodev,noexec", "/scratch", "nosuid,nodev,noexec"}, - {"/wsl/tmp:size=2g,uid=0,gid=0,mode=1777", "/wsl/tmp", "size=2g,uid=0,gid=0,mode=1777"}, + const std::vector> validTmpfsSpecs = { + {L"", "", ""}, + {L"/tmp", "/tmp", ""}, + {L"/tmp:size=50m", "/tmp", "size=50m"}, + {L"/var/tmp:size=1g", "/var/tmp", "size=1g"}, + {L"/tmp:size=50m,mode=1777", "/tmp", "size=50m,mode=1777"}, + {L"/cache:uid=1000,gid=1000", "/cache", "uid=1000,gid=1000"}, + {L"/mnt/ramdisk:size=256k,nr_inodes=1k", "/mnt/ramdisk", "size=256k,nr_inodes=1k"}, + {L"/securetmp:mode=0700", "/securetmp", "mode=0700"}, + {L"/scratch:nosuid,nodev,noexec", "/scratch", "nosuid,nodev,noexec"}, + {L"/wsl/tmp:size=2g,uid=0,gid=0,mode=1777", "/wsl/tmp", "size=2g,uid=0,gid=0,mode=1777"}, }; - for (const auto& [input, expectedContainerPath, expectedOptions] : validTmpfsSpecs) + for (const auto& [input, expectedTarget, expectedOptions] : validTmpfsSpecs) { - auto result = models::TmpfsMount::Parse(input); - VERIFY_ARE_EQUAL(expectedContainerPath, result.ContainerPath()); - VERIFY_ARE_EQUAL(expectedOptions, result.Options()); + const auto result = mount::ParseDockerTmpfsString(input); + VERIFY_ARE_EQUAL(static_cast(mount::Type::Tmpfs), static_cast(result.MountType)); + VERIFY_ARE_EQUAL(expectedTarget, result.Target); + VERIFY_IS_TRUE(result.TmpfsOptions.has_value()); + VERIFY_ARE_EQUAL(expectedOptions, result.TmpfsOptions.value()); } } + + TEST_METHOD(WSLCCLITmpfsMount_Validate) + { + auto valid = mount::ParseDockerTmpfsString(L"/tmp:size=50m"); + VERIFY_NO_THROW(mount::ValidateMountSpec(valid)); + + auto empty = mount::ParseDockerTmpfsString(L":size=50m"); + VERIFY_THROWS(mount::ValidateMountSpec(empty), mount::MountValidationException); + + auto relative = mount::ParseDockerTmpfsString(L"tmp:size=50m"); + VERIFY_THROWS(mount::ValidateMountSpec(relative), mount::MountValidationException); + } }; -} // namespace WSLCCLITmpfsParserUnitTests \ No newline at end of file +} // namespace WSLCCLITmpfsParserUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index 747d6d0cf..90a4a743d 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -46,6 +46,7 @@ class WSLCE2EContainerCreateTests EnsureImageIsDeleted(AlpineImage); EnsureImageIsDeleted(DebianImage); EnsureImageIsDeleted(HelloWorldImage); + EnsureVolumeDoesNotExist(WslcVolumeName); EnsureNetworkDoesNotExist(TestNetworkName); VERIFY_IS_TRUE(::SetEnvironmentVariableW(HostEnvVariableName.c_str(), nullptr)); @@ -61,6 +62,7 @@ class WSLCE2EContainerCreateTests VolumeTestFile1 = wsl::windows::common::filesystem::GetTempFilename(); VolumeTestFile2 = wsl::windows::common::filesystem::GetTempFilename(); EnsureContainerDoesNotExist(WslcContainerName); + EnsureVolumeDoesNotExist(WslcVolumeName); EnsureNetworkDoesNotExist(TestNetworkName); return true; } @@ -352,8 +354,7 @@ class WSLCE2EContainerCreateTests RunWslc(std::format(L"container run --name {} --volume :/containerPath {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: ':/containerPath'. Host path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeHostPathEmpty(L":/containerPath", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -362,8 +363,7 @@ class WSLCE2EContainerCreateTests std::format(L"container run --name {} --volume C:\\hostPath::ro {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath::ro'. Container path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathEmpty(L"C:\\hostPath::ro", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -372,17 +372,15 @@ class WSLCE2EContainerCreateTests std::format(L"container run --name {} --volume :/containerPath:ro {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: ':/containerPath:ro'. Host path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeHostPathEmpty(L":/containerPath:ro", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } { auto result = RunWslc(std::format(L"container run --name {} --volume \"\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE( - result.StderrContainsSubstring(L"Invalid volume specifications: ''. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_VolumeInvalidSpec(L"", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -391,8 +389,7 @@ class WSLCE2EContainerCreateTests RunWslc(std::format(L"container run --name {} --volume C:\\hostPath: {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath:'. Container path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathEmpty(L"C:\\hostPath:", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -401,17 +398,15 @@ class WSLCE2EContainerCreateTests RunWslc(std::format(L"container run --name {} --volume C:\\hostPath:ro {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath:ro'. Container path must be an absolute path (starting with '/'). " - L"Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathNotAbsolute(L"C:\\hostPath:ro", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } { auto result = RunWslc(std::format(L"container run --name {} --volume :ro {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE( - result.StderrContainsSubstring(L"Invalid volume specifications: ':ro'. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_VolumeInvalidSpec(L":ro", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -420,8 +415,7 @@ class WSLCE2EContainerCreateTests std::format(L"container run --name {} --volume C:\\hostPath::rw {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath::rw'. Container path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathEmpty(L"C:\\hostPath::rw", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -429,10 +423,8 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format( L"container run --name {} --volume C:\\hostPath:/containerPath:invalid_mode {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath:/containerPath:invalid_mode'. Container path must be an absolute " - L"path (starting with '/'). Expected format: :[:mode]\r\nError code: " - L"E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_VolumeContainerPathNotAbsolute( + L"C:\\hostPath:/containerPath:invalid_mode", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -440,10 +432,8 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format( L"container run --name {} --volume C:\\hostPath:/containerPath:ro:extra {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath:/containerPath:ro:extra'. Container path must be an absolute path " - L"(starting with '/'). Expected format: :[:mode]\r\nError code: " - L"E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_VolumeContainerPathNotAbsolute( + L"C:\\hostPath:/containerPath:ro:extra", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -451,9 +441,8 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format( L"container run --name {} --volume C:\\hostPath:/containerPath: {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath:/containerPath:'. Container path cannot be empty. Expected " - L"format: :[:mode]\r\nError code: E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_VolumeContainerPathEmpty( + L"C:\\hostPath:/containerPath:", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -462,9 +451,7 @@ class WSLCE2EContainerCreateTests auto result = RunWslc( std::format(L"container run --name {} --volume \"::/container:ro\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE( - result.StderrContainsSubstring(L"Invalid volume specifications: '::/container:ro'. Host path ':' is not a valid " - L"Windows path.\r\nError code: E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_VolumeHostPathInvalid(L"::/container:ro", L":"))); EnsureContainerDoesNotExist(WslcContainerName); } } @@ -479,8 +466,7 @@ class WSLCE2EContainerCreateTests std::format(L"container run --name {} --volume \"C:\\hostPath\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: 'C:\\hostPath'. Container path must be an absolute path (starting with '/'). " - L"Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathNotAbsolute(L"C:\\hostPath", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -488,8 +474,7 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format(L"container run --name {} --volume \":\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: ':'. Container path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathEmpty(L":", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -499,8 +484,7 @@ class WSLCE2EContainerCreateTests RunWslc(std::format(L"container run --name {} --volume \"::\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"Invalid volume specifications: '::'. Container path cannot be empty. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + Localization::WSLCCLI_VolumeContainerPathEmpty(L"::", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } @@ -508,9 +492,8 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format(L"container run --name {} --volume \"e2e_test\" {}", WslcContainerName, AlpineImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE( - result.StderrContainsSubstring(L"Invalid volume specifications: 'e2e_test'. Expected format: :[:mode]\r\nError code: E_INVALIDARG")); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_VolumeInvalidSpec(L"e2e_test", Localization::WSLCCLI_VolumeFormatUsage()))); EnsureContainerDoesNotExist(WslcContainerName); } } @@ -698,8 +681,7 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format(L"container create --name {} --tmpfs wslc-tmpfs {}", WslcContainerName, DebianImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE(result.StderrContainsSubstring( - L"invalid mount path: 'wslc-tmpfs' mount path must be absolute\r\nError code: E_FAIL")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_MountTargetAbsoluteError())); } WSLC_TEST_METHOD(WSLCE2E_Container_Create_Tmpfs_EmptyDestination_Fails) @@ -707,8 +689,242 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(std::format(L"container create --name {} --tmpfs :size=64k {}", WslcContainerName, DebianImage.NameAndTag())); result.Verify({.Stdout = L"", .ExitCode = 1}); - VERIFY_IS_TRUE( - result.StderrContainsSubstring(L"invalid mount path: '' mount path must be absolute\r\nError code: E_FAIL")); + VERIFY_IS_TRUE(result.StderrContainsSubstring(Localization::WSLCCLI_MountTargetRequiredError())); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_Tmpfs_Success) + { + auto result = RunWslc(std::format( + L"container create --name {} --mount type=tmpfs,target=/path:tmpfs,tmpfs-size=1MB,tmpfs-mode=0700 {} sh -c " + L"\"echo -n 'tmpfs_test' > /path:tmpfs/data && cat /path:tmpfs/data && echo && stat -c '%a' /path:tmpfs && " + L"df -k /path:tmpfs | awk 'NR == 2 {{print $2}}'\"", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_ARE_EQUAL(1u, inspect.Mounts.size()); + VERIFY_ARE_EQUAL("tmpfs", inspect.Mounts[0].Type); + VERIFY_ARE_EQUAL("", inspect.Mounts[0].Source); + VERIFY_ARE_EQUAL("/path:tmpfs", inspect.Mounts[0].Destination); + VERIFY_IS_TRUE(inspect.Mounts[0].ReadWrite); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"tmpfs_test\n700\n1024\n", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_Tmpfs_PreservesMountForm) + { + auto result = RunWslc(std::format( + L"container create --name {} --tmpfs /legacy-tmpfs --mount type=tmpfs,target=/modern-tmpfs,readonly {} true", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_ARE_EQUAL(2u, inspect.Mounts.size()); + + const auto legacyMount = + std::ranges::find_if(inspect.Mounts, [](const auto& mount) { return mount.Destination == "/legacy-tmpfs"; }); + VERIFY_IS_TRUE(legacyMount != inspect.Mounts.end()); + VERIFY_ARE_EQUAL("tmpfs", legacyMount->Type); + VERIFY_ARE_EQUAL("", legacyMount->Source); + VERIFY_IS_TRUE(legacyMount->ReadWrite); + + const auto modernMount = + std::ranges::find_if(inspect.Mounts, [](const auto& mount) { return mount.Destination == "/modern-tmpfs"; }); + VERIFY_IS_TRUE(modernMount != inspect.Mounts.end()); + VERIFY_ARE_EQUAL("tmpfs", modernMount->Type); + VERIFY_ARE_EQUAL("", modernMount->Source); + VERIFY_IS_FALSE(modernMount->ReadWrite); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_Bind_Success) + { + WriteTestFileContent(VolumeTestFile1, "WSLC Mount Bind Test"); + + const auto hostDirectory = VolumeTestFile1.parent_path(); + const auto fileName = VolumeTestFile1.filename().wstring(); + auto result = RunWslc(std::format( + L"container create --name {} --mount \"type=bind,source={},target=/path:mntdir,readonly\" {} cat /path:mntdir/{}", + WslcContainerName, + hostDirectory.wstring(), + DebianImage.NameAndTag(), + fileName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_ARE_EQUAL(1u, inspect.Mounts.size()); + VERIFY_ARE_EQUAL("bind", inspect.Mounts[0].Type); + VERIFY_ARE_EQUAL(std::filesystem::canonical(hostDirectory).string(), inspect.Mounts[0].Source); + VERIFY_ARE_EQUAL("/path:mntdir", inspect.Mounts[0].Destination); + VERIFY_IS_FALSE(inspect.Mounts[0].ReadWrite); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"WSLC Mount Bind Test", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_Bind_MissingSource_Fails) + { + const auto source = VolumeTestFile1; + VERIFY_IS_TRUE(DeleteFileW(source.c_str())); + auto cleanupSource = wil::scope_exit([&]() { + std::error_code error; + std::filesystem::remove_all(source, error); + }); + + auto result = RunWslc(std::format( + L"container run --name {} --mount \"type=bind,source={},target=/data\" {} true", + WslcContainerName, + source.wstring(), + AlpineImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = FormatWslcError(Localization::MessageWslcBindSourcePathNotFound(source.wstring())), .ExitCode = 1}); + VERIFY_IS_FALSE(std::filesystem::exists(source)); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Volume_MissingSource_CreatesDirectory) + { + const auto source = VolumeTestFile1; + VERIFY_IS_TRUE(DeleteFileW(source.c_str())); + auto cleanupSource = wil::scope_exit([&]() { + std::error_code error; + std::filesystem::remove_all(source, error); + }); + + auto result = RunWslc(std::format( + L"container run --name {} --volume \"{}:/data\" {} true", WslcContainerName, source.wstring(), AlpineImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0}); + VERIFY_IS_TRUE(std::filesystem::is_directory(source)); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_Volume_Success) + { + auto result = RunWslc(std::format( + L"container create --name {} --mount type=volume,source={},target=/path:voldir {} sh -c \"echo -n 'WSLC Mount Volume " + L"Test' > /path:voldir/test.txt\"", + WslcContainerName, + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto inspect = InspectContainer(WslcContainerName); + VERIFY_ARE_EQUAL(1u, inspect.Mounts.size()); + VERIFY_ARE_EQUAL("volume", inspect.Mounts[0].Type); + VERIFY_ARE_EQUAL(string::WideToMultiByte(WslcVolumeName), inspect.Mounts[0].Source); + VERIFY_ARE_EQUAL("/path:voldir", inspect.Mounts[0].Destination); + VERIFY_IS_TRUE(inspect.Mounts[0].ReadWrite); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0}); + EnsureContainerDoesNotExist(WslcContainerName); + + result = RunWslc(std::format( + L"container create --name {} --mount type=volume,source={},target=/path:voldir {} cat /path:voldir/test.txt", + WslcContainerName, + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"WSLC Mount Volume Test", .Stderr = L"", .ExitCode = 0}); + EnsureContainerDoesNotExist(WslcContainerName); + + result = RunWslc(std::format( + L"container create --rm --name {} --mount type=volume,target=/anonymous {} sh -c " + L"\"echo -n anonymous-volume > /anonymous/value && cat /anonymous/value\"", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto anonymousInspect = InspectContainer(WslcContainerName); + VERIFY_ARE_EQUAL(1u, anonymousInspect.Mounts.size()); + VERIFY_ARE_EQUAL("volume", anonymousInspect.Mounts[0].Type); + VERIFY_IS_FALSE(anonymousInspect.Mounts[0].Name.empty()); + VERIFY_IS_TRUE(anonymousInspect.Mounts[0].Source.empty()); + VERIFY_ARE_EQUAL("/anonymous", anonymousInspect.Mounts[0].Destination); + VERIFY_IS_TRUE(anonymousInspect.Mounts[0].ReadWrite); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"anonymous-volume", .Stderr = L"", .ExitCode = 0}); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_ReadOnly_IsReadOnly) + { + auto result = RunWslc(std::format( + L"container create --name {} --mount type=volume,source={},target=/data {} sh -c \"echo -n original > /data/value\"", + WslcContainerName, + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0}); + EnsureContainerDoesNotExist(WslcContainerName); + + result = RunWslc(std::format( + L"container create --name {} --mount type=volume,source={},target=/data,readonly {} sh -c \"echo changed > " + L"/data/value\"", + WslcContainerName, + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"", .Stderr = L"sh: 1: cannot create /data/value: Read-only file system\n", .ExitCode = 2}); + EnsureContainerDoesNotExist(WslcContainerName); + + result = RunWslc(std::format( + L"container create --name {} --mount type=volume,source={},target=/data {} cat /data/value", + WslcContainerName, + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format(L"container start -a {}", WslcContainerName)); + result.Verify({.Stdout = L"original", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_InvalidType_Fails) + { + constexpr auto mount = L"type=bogus,target=/x"; + auto result = + RunWslc(std::format(L"container create --name {} --mount {} {} true", WslcContainerName, mount, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .ExitCode = 1}); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_UnsupportedMountError(mount, Localization::WSLCCLI_MountTypeUnsupportedError(L"bogus")))); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_RelativeTarget_Fails) + { + constexpr auto mount = L"type=tmpfs,target=data"; + auto result = + RunWslc(std::format(L"container create --name {} --mount {} {} true", WslcContainerName, mount, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .ExitCode = 1}); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_InvalidMountError(mount, Localization::WSLCCLI_MountTargetAbsoluteError()))); + EnsureContainerDoesNotExist(WslcContainerName); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Create_Mount_DuplicateDestination_Fails) + { + constexpr std::wstring_view duplicateMountArguments[] = { + L"--mount type=tmpfs,target=/data --mount type=tmpfs,target=/data/", + L"--tmpfs /data --volume data-volume:/data/", + L"--tmpfs /data --mount type=volume,source=data-volume,target=/data/", + L"--volume data-volume:/data --mount type=tmpfs,target=/data/", + }; + + for (const auto arguments : duplicateMountArguments) + { + const auto result = + RunWslc(std::format(L"container create --name {} {} {} true", WslcContainerName, arguments, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = FormatWslcError(Localization::WSLCCLI_DuplicateMountDestinationError(L"/data")), .ExitCode = 1}); + EnsureContainerDoesNotExist(WslcContainerName); + } } WSLC_TEST_METHOD(WSLCE2E_Container_Create_WorkDir) @@ -1571,6 +1787,9 @@ while True: // Test network name const std::wstring TestNetworkName = L"wslc-test-network"; + // Test named volume + const std::wstring WslcVolumeName = L"wslc-test-volume"; + // Test environment variables const std::wstring HostEnvVariableName = L"WSLC_TEST_HOST_ENV"; const std::wstring HostEnvVariableName2 = L"WSLC_TEST_HOST_ENV2"; diff --git a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp index 84e50f542..d1d84407c 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp @@ -17,6 +17,7 @@ Module Name: #include "WSLCE2EHelpers.h" namespace WSLCE2ETests { +using namespace wsl::shared; class WSLCE2EContainerRunTests { @@ -1071,6 +1072,81 @@ class WSLCE2EContainerRunTests result.Verify({.Stderr = L"", .ExitCode = 0}); } + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_Tmpfs_Success) + { + auto result = RunWslc(std::format( + L"container run --rm --mount type=tmpfs,target=/wslc-tmpfs {} sh -c \"echo -n 'tmpfs_test' > /wslc-tmpfs/data && cat " + L"/wslc-tmpfs/data\"", + DebianImage.NameAndTag())); + result.Verify({.Stdout = L"tmpfs_test", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_Bind_Success) + { + WriteTestFileContent(EnvTestFile1, "WSLC Mount Bind Test"); + + const auto hostDirectory = EnvTestFile1.parent_path(); + const auto fileName = EnvTestFile1.filename().wstring(); + auto result = RunWslc(std::format( + L"container run --rm --mount \"type=bind,source={},target=/data,readonly\" {} cat /data/{}", + hostDirectory.wstring(), + DebianImage.NameAndTag(), + fileName)); + result.Verify({.Stdout = L"WSLC Mount Bind Test", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_Volume_Success) + { + auto result = RunWslc(std::format( + L"container run --rm --mount type=volume,source={},target=/data {} sh -c \"echo -n 'WSLC Mount Volume Test' > " + L"/data/test.txt\"", + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format( + L"container run --rm --mount type=volume,source={},target=/data {} cat /data/test.txt", WslcVolumeName, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"WSLC Mount Volume Test", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_ReadOnly_IsReadOnly) + { + auto result = RunWslc(std::format( + L"container run --rm --mount type=volume,source={},target=/data {} sh -c \"echo -n original > /data/value\"", + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0}); + + result = RunWslc(std::format( + L"container run --rm --mount type=volume,source={},target=/data,readonly {} sh -c \"echo changed > /data/value\"", + WslcVolumeName, + DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = L"sh: 1: cannot create /data/value: Read-only file system\n", .ExitCode = 2}); + + result = RunWslc(std::format( + L"container run --rm --mount type=volume,source={},target=/data {} cat /data/value", WslcVolumeName, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"original", .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_InvalidType_Fails) + { + constexpr auto mount = L"type=bogus,target=/x"; + auto result = RunWslc(std::format(L"container run --rm --mount {} {} true", mount, DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .ExitCode = 1}); + VERIFY_IS_TRUE(result.StderrContainsSubstring( + Localization::WSLCCLI_UnsupportedMountError(mount, Localization::WSLCCLI_MountTypeUnsupportedError(L"bogus")))); + } + + WSLC_TEST_METHOD(WSLCE2E_Container_Run_Mount_DuplicateDestination_Fails) + { + auto result = RunWslc(std::format( + L"container run --rm --name {} --mount type=tmpfs,target=/data --mount type=tmpfs,target=/data/ {} true", + WslcContainerName, + DebianImage.NameAndTag())); + result.Verify({.Stdout = L"", .Stderr = FormatWslcError(Localization::WSLCCLI_DuplicateMountDestinationError(L"/data")), .ExitCode = 1}); + EnsureContainerDoesNotExist(WslcContainerName); + } + WSLC_TEST_METHOD(WSLCE2E_Container_Run_WithLabel_Success) { auto result = RunWslc(std::format( diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.h b/test/windows/wslc/e2e/WSLCE2EHelpers.h index 9c692aab0..355574302 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.h +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h @@ -23,6 +23,11 @@ Module Name: namespace WSLCE2ETests { +inline std::wstring FormatWslcError(const std::wstring& message, std::wstring_view errorCode = L"E_INVALIDARG") +{ + return std::format(L"{}\r\nError code: {}\r\n", message, errorCode); +} + // VT sequence constants and helpers for TTY testing. // Sequences are sourced from wsl::windows::common::vt (VTSupport.h). namespace VT {