master
cpp 691 lines 20.6 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 MountSpecParsing.cpp
8
9 Abstract:
10
11 Docker-compatible mount specification parsing.
12
13 --*/
14
15 #include "precomp.h"
16 #include "MountSpecParsing.h"
17 #include "string.hpp"
18 #include <algorithm>
19 #include <array>
20 #include <filesystem>
21 #include <format>
22 #include <limits>
23 #include <regex>
24 #include <unordered_set>
25 #include <vector>
26
27 using namespace wsl::shared;
28 using namespace wsl::shared::string;
29
30 namespace wsl::windows::common::mount {
31
32 namespace {
33
34 enum class Field
35 {
36 Type,
37 Source,
38 Target,
39 ReadOnly,
40 Consistency,
41 BindPropagation,
42 BindNonRecursive,
43 BindRecursive,
44 VolumeNoCopy,
45 VolumeLabel,
46 VolumeDriver,
47 VolumeOption,
48 TmpfsSize,
49 TmpfsMode,
50 };
51
52 enum class Family
53 {
54 General,
55 Bind,
56 Volume,
57 Tmpfs,
58 };
59
60 enum class Support
61 {
62 Supported,
63 Unsupported,
64 ValueDependent,
65 };
66
67 struct FieldDefinition
68 {
69 std::wstring_view Name;
70 Field Id;
71 Family OptionFamily;
72 bool AllowsBareForm;
73 Support SupportLevel;
74 };
75
76 // Keep this table aligned with docker/cli v25.0.3 opts/mount.go. It is the
77 // authoritative list of accepted fields, aliases, option families, and WSLC support.
78 constexpr std::array c_fieldDefinitions{
79 FieldDefinition{L"type", Field::Type, Family::General, false, Support::ValueDependent},
80 FieldDefinition{L"source", Field::Source, Family::General, false, Support::Supported},
81 FieldDefinition{L"src", Field::Source, Family::General, false, Support::Supported},
82 FieldDefinition{L"target", Field::Target, Family::General, false, Support::Supported},
83 FieldDefinition{L"dst", Field::Target, Family::General, false, Support::Supported},
84 FieldDefinition{L"destination", Field::Target, Family::General, false, Support::Supported},
85 FieldDefinition{L"readonly", Field::ReadOnly, Family::General, true, Support::Supported},
86 FieldDefinition{L"ro", Field::ReadOnly, Family::General, true, Support::Supported},
87 // The WSLC mount transport and Docker request model have no end-to-end consistency setting.
88 FieldDefinition{L"consistency", Field::Consistency, Family::General, false, Support::Unsupported},
89 // The WSLC mount transport and Docker request model do not carry Docker bind options.
90 // "enabled" requires no non-default bind behavior and is accepted below.
91 FieldDefinition{L"bind-propagation", Field::BindPropagation, Family::Bind, false, Support::Unsupported},
92 FieldDefinition{L"bind-nonrecursive", Field::BindNonRecursive, Family::Bind, true, Support::Unsupported},
93 FieldDefinition{L"bind-recursive", Field::BindRecursive, Family::Bind, false, Support::ValueDependent},
94 // The mount pipeline relies on Docker's default volume copy-up behavior and does not carry VolumeOptions.
95 FieldDefinition{L"volume-nocopy", Field::VolumeNoCopy, Family::Volume, true, Support::Unsupported},
96 // Inline mounts reference volumes by name; the volume creation API owns labels, drivers, and driver options.
97 FieldDefinition{L"volume-label", Field::VolumeLabel, Family::Volume, false, Support::Unsupported},
98 FieldDefinition{L"volume-driver", Field::VolumeDriver, Family::Volume, false, Support::Unsupported},
99 FieldDefinition{L"volume-opt", Field::VolumeOption, Family::Volume, false, Support::Unsupported},
100 FieldDefinition{L"tmpfs-size", Field::TmpfsSize, Family::Tmpfs, false, Support::Supported},
101 FieldDefinition{L"tmpfs-mode", Field::TmpfsMode, Family::Tmpfs, false, Support::Supported},
102 };
103
104 struct DockerMountSpec
105 {
106 std::wstring Type = L"volume";
107 std::wstring Source;
108 std::wstring Target;
109 bool ReadOnly = false;
110 bool HasVolumeOptions = false;
111 bool HasBindOptions = false;
112 bool HasTmpfsOptions = false;
113 bool BindReadOnlyNonRecursive = false;
114 bool BindReadOnlyForceRecursive = false;
115 std::wstring BindPropagation;
116 std::optional<int64_t> TmpfsSizeBytes;
117 std::optional<uint32_t> TmpfsMode;
118 std::optional<std::wstring> UnsupportedOption;
119 };
120
121 struct KeyValue
122 {
123 std::wstring Key;
124 std::wstring Value;
125 bool HadSeparator;
126 };
127
128 [[noreturn]] void ThrowValidation(std::wstring reason)
129 {
130 throw MountValidationException(std::move(reason));
131 }
132
133 [[noreturn]] void ThrowParse(std::wstring reason)
134 {
135 throw MountParseException(std::move(reason));
136 }
137
138 [[noreturn]] void ThrowUnsupported(std::wstring reason)
139 {
140 throw MountUnsupportedException(std::move(reason));
141 }
142
143 KeyValue SplitKeyValue(const std::wstring& value)
144 {
145 const auto position = value.find(L'=');
146 if (position == std::wstring::npos)
147 {
148 return {.Key = value, .HadSeparator = false};
149 }
150
151 return {.Key = value.substr(0, position), .Value = value.substr(position + 1), .HadSeparator = true};
152 }
153
154 const FieldDefinition* FindField(std::wstring_view name)
155 {
156 const auto found = std::ranges::find_if(c_fieldDefinitions, [&](const auto& definition) { return definition.Name == name; });
157 return found == c_fieldDefinitions.end() ? nullptr : &*found;
158 }
159
160 void RecordUnsupportedOption(DockerMountSpec& mount, std::wstring_view option)
161 {
162 if (!mount.UnsupportedOption.has_value())
163 {
164 mount.UnsupportedOption = option;
165 }
166 }
167
168 std::optional<int64_t> ParseDockerRamInBytes(const std::wstring& value)
169 {
170 const auto parsed = wsl::windows::common::string::ParseStorageSize(value, wsl::windows::common::string::StorageSizeUnit::Binary);
171 if (!parsed.has_value() || parsed.value() > static_cast<uint64_t>(std::numeric_limits<int64_t>::max()))
172 {
173 return std::nullopt;
174 }
175
176 return static_cast<int64_t>(parsed.value());
177 }
178
179 std::optional<uint32_t> ParseDockerTmpfsMode(const std::wstring& value)
180 {
181 if (value.empty())
182 {
183 return std::nullopt;
184 }
185
186 uint64_t result = 0;
187 for (const auto digit : value)
188 {
189 if (digit < L'0' || digit > L'7')
190 {
191 return std::nullopt;
192 }
193
194 result = (result * 8) + static_cast<uint64_t>(digit - L'0');
195 if (result > std::numeric_limits<uint32_t>::max())
196 {
197 return std::nullopt;
198 }
199 }
200
201 return static_cast<uint32_t>(result);
202 }
203
204 std::string FormatDockerTmpfsSize(int64_t sizeBytes)
205 {
206 for (const auto& [suffix, divisor] : std::array<std::pair<char, int64_t>, 3>{{{'g', 1LL << 30}, {'m', 1LL << 20}, {'k', 1LL << 10}}})
207 {
208 if ((sizeBytes % divisor) == 0)
209 {
210 return std::format("{}{}", sizeBytes / divisor, suffix);
211 }
212 }
213
214 return std::to_string(sizeBytes);
215 }
216
217 } // namespace
218
219 Spec ParseDockerMountString(const std::wstring& value)
220 {
221 const auto fields = SplitCsvFields(value);
222 if (!fields.has_value())
223 {
224 ThrowParse(Localization::WSLCCLI_MountMalformedCsvError());
225 }
226
227 DockerMountSpec mount;
228
229 for (const auto& field : *fields)
230 {
231 const auto keyValue = SplitKeyValue(field);
232 const auto key = AsciiToLower(std::wstring_view(keyValue.Key));
233 const auto definition = FindField(key);
234 if (definition == nullptr)
235 {
236 if (!keyValue.HadSeparator)
237 {
238 ThrowParse(Localization::WSLCCLI_MountFieldKeyValueRequiredError(field));
239 }
240
241 ThrowParse(Localization::WSLCCLI_MountUnexpectedKeyError(key, field));
242 }
243
244 if (!keyValue.HadSeparator && !definition->AllowsBareForm)
245 {
246 ThrowParse(Localization::WSLCCLI_MountFieldKeyValueRequiredError(field));
247 }
248
249 switch (definition->OptionFamily)
250 {
251 case Family::General:
252 break;
253 case Family::Bind:
254 if (definition->Id != Field::BindRecursive || keyValue.Value != L"enabled")
255 {
256 mount.HasBindOptions = true;
257 }
258 break;
259 case Family::Volume:
260 mount.HasVolumeOptions = true;
261 break;
262 case Family::Tmpfs:
263 mount.HasTmpfsOptions = true;
264 break;
265 }
266
267 switch (definition->Id)
268 {
269 case Field::Type:
270 mount.Type = AsciiToLower(std::wstring_view(keyValue.Value));
271 break;
272
273 case Field::Source:
274 mount.Source = keyValue.Value;
275 if (mount.Source == L"." || mount.Source.starts_with(L".\\"))
276 {
277 std::error_code error;
278 auto absolutePath = std::filesystem::absolute(mount.Source, error);
279 if (!error)
280 {
281 mount.Source = absolutePath.lexically_normal().wstring();
282 }
283 }
284 break;
285
286 case Field::Target:
287 mount.Target = keyValue.Value;
288 break;
289
290 case Field::ReadOnly:
291 if (!keyValue.HadSeparator)
292 {
293 mount.ReadOnly = true;
294 break;
295 }
296
297 if (const auto parsed = ParseBool(keyValue.Value.c_str(), true); parsed.has_value())
298 {
299 mount.ReadOnly = parsed.value();
300 }
301 else
302 {
303 ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value));
304 }
305 break;
306
307 case Field::Consistency:
308 break;
309
310 case Field::BindPropagation:
311 mount.BindPropagation = AsciiToLower(std::wstring_view(keyValue.Value));
312 break;
313
314 case Field::BindNonRecursive:
315 if (keyValue.HadSeparator && !ParseBool(keyValue.Value.c_str(), true).has_value())
316 {
317 ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value));
318 }
319
320 break;
321
322 case Field::BindRecursive:
323 if (keyValue.Value == L"enabled")
324 {
325 break;
326 }
327
328 RecordUnsupportedOption(mount, key);
329 if (keyValue.Value == L"disabled")
330 {
331 break;
332 }
333 if (keyValue.Value == L"writable")
334 {
335 mount.BindReadOnlyNonRecursive = true;
336 break;
337 }
338 if (keyValue.Value == L"readonly")
339 {
340 mount.BindReadOnlyForceRecursive = true;
341 break;
342 }
343
344 ThrowParse(Localization::WSLCCLI_MountInvalidBindRecursiveValueError(key, keyValue.Value));
345
346 case Field::VolumeNoCopy:
347 if (keyValue.HadSeparator && !ParseBool(keyValue.Value.c_str(), true).has_value())
348 {
349 ThrowParse(Localization::WSLCCLI_MountInvalidValueError(L"volume-nocopy", keyValue.Value));
350 }
351
352 break;
353
354 case Field::VolumeLabel:
355 case Field::VolumeDriver:
356 case Field::VolumeOption:
357 break;
358
359 case Field::TmpfsSize:
360 mount.TmpfsSizeBytes = ParseDockerRamInBytes(keyValue.Value);
361 if (!mount.TmpfsSizeBytes.has_value())
362 {
363 ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value));
364 }
365
366 break;
367
368 case Field::TmpfsMode:
369 mount.TmpfsMode = ParseDockerTmpfsMode(keyValue.Value);
370 if (!mount.TmpfsMode.has_value())
371 {
372 ThrowParse(Localization::WSLCCLI_MountInvalidValueError(key, keyValue.Value));
373 }
374
375 break;
376 }
377
378 if (definition->SupportLevel == Support::Unsupported)
379 {
380 RecordUnsupportedOption(mount, key);
381 }
382 }
383
384 if (mount.Type.empty())
385 {
386 ThrowParse(Localization::WSLCCLI_MountTypeRequiredError());
387 }
388
389 if (mount.HasVolumeOptions && mount.Type != L"volume")
390 {
391 ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"volume-*", mount.Type));
392 }
393 if (mount.HasBindOptions && mount.Type != L"bind")
394 {
395 ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"bind-*", mount.Type));
396 }
397 if (mount.HasTmpfsOptions && mount.Type != L"tmpfs")
398 {
399 ThrowParse(Localization::WSLCCLI_MountOptionFamilyMismatchError(L"tmpfs-*", mount.Type));
400 }
401
402 if (mount.BindReadOnlyNonRecursive && !mount.ReadOnly)
403 {
404 ThrowParse(Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=writable"));
405 }
406 if (mount.BindReadOnlyForceRecursive)
407 {
408 if (!mount.ReadOnly)
409 {
410 ThrowParse(Localization::WSLCCLI_MountOptionRequiresReadonlyError(L"bind-recursive=readonly"));
411 }
412 if (mount.BindPropagation != L"rprivate")
413 {
414 ThrowParse(Localization::WSLCCLI_MountBindRecursiveReadonlyRequiresPropagationError());
415 }
416 }
417
418 Type type;
419 if (mount.Type == L"bind")
420 {
421 type = WSLCMountTypeBind;
422 }
423 else if (mount.Type == L"volume")
424 {
425 type = WSLCMountTypeVolume;
426 }
427 else if (mount.Type == L"tmpfs")
428 {
429 type = WSLCMountTypeTmpfs;
430 }
431 else
432 {
433 ThrowUnsupported(Localization::WSLCCLI_MountTypeUnsupportedError(mount.Type));
434 }
435
436 if (mount.UnsupportedOption.has_value())
437 {
438 ThrowUnsupported(Localization::WSLCCLI_MountOptionUnsupportedError(mount.UnsupportedOption.value()));
439 }
440
441 return {
442 .MountType = type,
443 .Source = std::move(mount.Source),
444 .Target = WideToMultiByte(mount.Target),
445 .ReadOnly = mount.ReadOnly,
446 .TmpfsSizeBytes = mount.TmpfsSizeBytes,
447 .TmpfsMode = mount.TmpfsMode,
448 };
449 }
450
451 Spec ParseDockerVolumeString(const std::wstring& value)
452 {
453 const auto formatUsage = Localization::WSLCCLI_VolumeFormatUsage();
454 const auto lastColon = value.rfind(L':');
455 if (lastColon == std::wstring::npos)
456 {
457 ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage));
458 }
459
460 auto splitColon = lastColon;
461 bool readOnly = false;
462 std::wstring_view lastToken{value.data() + lastColon + 1, value.size() - lastColon - 1};
463 if (lastToken == L"ro" || lastToken == L"rw")
464 {
465 readOnly = lastToken == L"ro";
466 if (lastColon == 0)
467 {
468 ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage));
469 }
470
471 splitColon = value.rfind(L':', lastColon - 1);
472 if (splitColon == std::wstring::npos)
473 {
474 ThrowParse(Localization::WSLCCLI_VolumeInvalidSpec(value, formatUsage));
475 }
476 }
477
478 const auto targetEnd = lastToken == L"ro" || lastToken == L"rw" ? lastColon : value.size();
479 const auto target = value.substr(splitColon + 1, targetEnd - splitColon - 1);
480 if (target.empty())
481 {
482 ThrowParse(Localization::WSLCCLI_VolumeContainerPathEmpty(value, formatUsage));
483 }
484
485 if (target.front() != L'/')
486 {
487 ThrowParse(Localization::WSLCCLI_VolumeContainerPathNotAbsolute(value, formatUsage));
488 }
489
490 const auto rawSource = value.substr(0, splitColon);
491 if (rawSource.empty())
492 {
493 ThrowParse(Localization::WSLCCLI_VolumeHostPathEmpty(value, formatUsage));
494 }
495
496 if (IsValidNamedVolumeName(rawSource))
497 {
498 return {
499 .MountType = WSLCMountTypeVolume,
500 .Source = rawSource,
501 .Target = WideToMultiByte(target),
502 .ReadOnly = readOnly,
503 };
504 }
505
506 std::error_code error;
507 auto source = wsl::windows::common::filesystem::GetCanonicalPath(rawSource, error);
508 if (error)
509 {
510 ThrowParse(Localization::WSLCCLI_VolumeHostPathInvalid(value, rawSource));
511 }
512
513 if (GetFileAttributesW(source.c_str()) == INVALID_FILE_ATTRIBUTES && GetLastError() == ERROR_INVALID_NAME)
514 {
515 ThrowParse(Localization::WSLCCLI_VolumeHostPathInvalid(value, rawSource));
516 }
517
518 return {
519 .MountType = WSLCMountTypeBind,
520 .Source = source.wstring(),
521 .Target = WideToMultiByte(target),
522 .ReadOnly = readOnly,
523 .BindSource = BindSourcePolicy::CreateIfMissing,
524 };
525 }
526
527 Spec ParseDockerTmpfsString(const std::wstring& value)
528 {
529 const auto colon = value.find(L':');
530 const auto target = value.substr(0, colon);
531 const auto options = colon == std::wstring::npos ? std::wstring_view{} : std::wstring_view{value}.substr(colon + 1);
532
533 return {
534 .MountType = WSLCMountTypeTmpfs,
535 .Target = WideToMultiByte(target),
536 .TmpfsOptions = WideToMultiByte(std::wstring{options}),
537 };
538 }
539
540 void ValidateMountSpec(const Spec& mount)
541 {
542 if (mount.Target.empty())
543 {
544 ThrowValidation(Localization::WSLCCLI_MountTargetRequiredError());
545 }
546
547 if (!mount.Target.starts_with('/'))
548 {
549 ThrowValidation(Localization::WSLCCLI_MountTargetAbsoluteError());
550 }
551
552 if (mount.MountType != WSLCMountTypeTmpfs &&
553 (mount.TmpfsSizeBytes.has_value() || mount.TmpfsMode.has_value() || mount.TmpfsOptions.has_value()))
554 {
555 ThrowValidation(Localization::WSLCCLI_MountTmpfsOptionsTypeError());
556 }
557
558 if (mount.TmpfsSizeBytes.has_value() && mount.TmpfsSizeBytes.value() < 0)
559 {
560 ThrowValidation(Localization::WSLCCLI_MountTmpfsSizeNegativeError());
561 }
562
563 switch (mount.MountType)
564 {
565 case WSLCMountTypeBind:
566 if (mount.Source.empty())
567 {
568 ThrowValidation(Localization::WSLCCLI_MountSourceRequiredError());
569 }
570
571 if (!std::filesystem::path(mount.Source).is_absolute())
572 {
573 ThrowValidation(Localization::WSLCCLI_MountBindSourceAbsoluteError());
574 }
575 break;
576
577 case WSLCMountTypeVolume:
578 if (!mount.Source.empty() && !IsValidNamedVolumeName(mount.Source))
579 {
580 ThrowValidation(Localization::WSLCCLI_MountVolumeSourceInvalidError());
581 }
582 break;
583
584 case WSLCMountTypeTmpfs:
585 if (!mount.Source.empty())
586 {
587 ThrowValidation(Localization::WSLCCLI_MountTmpfsSourceUnsupportedError());
588 }
589 break;
590
591 default:
592 ThrowUnsupported(Localization::WSLCCLI_MountTypeUnsupportedGenericError());
593 }
594 }
595
596 void ValidateMountCollection(std::span<const Spec> mounts)
597 {
598 std::unordered_set<std::string> destinations;
599 for (const auto& mount : mounts)
600 {
601 ValidateMountSpec(mount);
602
603 auto destination = NormalizeDestination(mount.Target);
604 if (!destinations.emplace(destination).second)
605 {
606 throw MountValidationException(
607 ValidationError::DuplicateDestination,
608 Localization::WSLCCLI_DuplicateMountDestinationError(MultiByteToWide(destination)),
609 std::move(destination));
610 }
611 }
612 }
613
614 std::string FormatTmpfsOptions(const Spec& mount)
615 {
616 WI_ASSERT(mount.MountType == WSLCMountTypeTmpfs);
617
618 if (mount.TmpfsOptions.has_value())
619 {
620 return mount.TmpfsOptions.value();
621 }
622
623 std::vector<std::string> options;
624 if (mount.ReadOnly)
625 {
626 options.emplace_back("ro");
627 }
628 if (mount.TmpfsMode.has_value() && mount.TmpfsMode.value() != 0)
629 {
630 options.emplace_back(std::format("mode={:o}", mount.TmpfsMode.value()));
631 }
632 if (mount.TmpfsSizeBytes.has_value() && mount.TmpfsSizeBytes.value() != 0)
633 {
634 options.emplace_back(std::format("size={}", FormatDockerTmpfsSize(mount.TmpfsSizeBytes.value())));
635 }
636
637 return wsl::shared::string::Join<char>(options, ',');
638 }
639
640 std::string NormalizeDestination(std::string destination)
641 {
642 std::vector<std::string> components;
643 size_t start = 0;
644 while (start <= destination.size())
645 {
646 const auto end = destination.find('/', start);
647 const auto component = destination.substr(start, end - start);
648 if (!component.empty() && component != ".")
649 {
650 if (component == "..")
651 {
652 if (!components.empty())
653 {
654 components.pop_back();
655 }
656 }
657 else
658 {
659 components.emplace_back(component);
660 }
661 }
662
663 if (end == std::string::npos)
664 {
665 break;
666 }
667
668 start = end + 1;
669 }
670
671 std::string result = "/";
672 for (const auto& component : components)
673 {
674 if (result.size() > 1)
675 {
676 result += '/';
677 }
678
679 result += component;
680 }
681
682 return result;
683 }
684
685 bool IsValidNamedVolumeName(std::wstring_view name)
686 {
687 static const std::wregex c_namedVolumeRegex(LR"(^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,}$)");
688 return std::regex_match(name.begin(), name.end(), c_namedVolumeRegex);
689 }
690
691 } // namespace wsl::windows::common::mount