runner_manager_platform/wsl/
discovery.rs1use sha2::{Digest, Sha256};
57
58use super::WslError;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ConsoleEncoding {
63 Utf16Le,
65 Utf8,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct DecodedOutput {
72 text: String,
73 encoding: ConsoleEncoding,
74 lossy: bool,
75}
76
77impl DecodedOutput {
78 #[must_use]
80 pub fn text(&self) -> &str {
81 &self.text
82 }
83
84 #[must_use]
86 pub fn into_text(self) -> String {
87 self.text
88 }
89
90 #[must_use]
92 pub fn encoding(&self) -> ConsoleEncoding {
93 self.encoding
94 }
95
96 #[must_use]
102 pub fn is_lossy(&self) -> bool {
103 self.lossy
104 }
105}
106
107const UTF16_NUL_RATIO_NUMERATOR: usize = 6;
117const UTF16_NUL_RATIO_DENOMINATOR: usize = 10;
119
120#[must_use]
122pub fn decode_console_output(bytes: &[u8]) -> DecodedOutput {
123 if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
124 return decode_utf16le(rest);
125 }
126 if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
127 return decode_utf8(rest);
128 }
129 if looks_like_utf16le(bytes) {
130 return decode_utf16le(bytes);
131 }
132 decode_utf8(bytes)
133}
134
135fn looks_like_utf16le(bytes: &[u8]) -> bool {
137 if bytes.len() < 2 || !bytes.len().is_multiple_of(2) {
138 return false;
139 }
140 let pairs = bytes.len() / 2;
141 let nul_high_bytes = bytes
142 .iter()
143 .skip(1)
144 .step_by(2)
145 .filter(|byte| **byte == 0)
146 .count();
147 if nul_high_bytes == 0 {
148 return false;
149 }
150 nul_high_bytes * UTF16_NUL_RATIO_DENOMINATOR >= pairs * UTF16_NUL_RATIO_NUMERATOR
151}
152
153fn decode_utf16le(bytes: &[u8]) -> DecodedOutput {
154 let truncated = !bytes.len().is_multiple_of(2);
158 let units: Vec<u16> = bytes
159 .chunks_exact(2)
160 .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
161 .collect();
162 let text = String::from_utf16_lossy(&units);
163 let lossy = truncated || text.contains(char::REPLACEMENT_CHARACTER);
164 DecodedOutput {
165 text,
166 encoding: ConsoleEncoding::Utf16Le,
167 lossy,
168 }
169}
170
171fn decode_utf8(bytes: &[u8]) -> DecodedOutput {
172 match std::str::from_utf8(bytes) {
173 Ok(text) => DecodedOutput {
174 text: text.to_string(),
175 encoding: ConsoleEncoding::Utf8,
176 lossy: false,
177 },
178 Err(_) => DecodedOutput {
179 text: String::from_utf8_lossy(bytes).into_owned(),
180 encoding: ConsoleEncoding::Utf8,
181 lossy: true,
182 },
183 }
184}
185
186pub const MAX_DISTRIBUTION_NAME: usize = 255;
196
197pub fn validate_distribution_name(name: &str) -> Result<(), WslError> {
218 let refuse = |reason: &str| {
219 Err(WslError::InvalidName {
220 requested: name.to_string(),
221 reason: reason.to_string(),
222 })
223 };
224 if name.is_empty() {
225 return refuse("it is empty, so it names no distribution");
226 }
227 if name.trim() != name {
228 return refuse(
229 "it starts or ends with whitespace, which no `wsl --list` row reports and which \
230 would make two different names print identically",
231 );
232 }
233 if name.chars().count() > MAX_DISTRIBUTION_NAME {
234 return refuse("it is longer than a distribution name may be here");
235 }
236 if name.chars().any(char::is_control) {
237 return refuse(
238 "it contains a control character, which cannot survive an argument vector or a \
239 scheduled-task document intact",
240 );
241 }
242 if name.starts_with('-') {
243 return refuse(
244 "it starts with `-`, so `wsl.exe` would read it as an option rather than as the \
245 distribution to select",
246 );
247 }
248 if name.contains(char::REPLACEMENT_CHARACTER) {
249 return refuse(
250 "it contains a Unicode replacement character, which means it was already damaged \
251 by a decoding step and is not the name of anything",
252 );
253 }
254 Ok(())
255}
256
257pub(crate) const ESCAPED_NAME_BUDGET: usize = 48;
260
261pub(crate) const DIGEST_SUFFIX_LENGTH: usize = 8;
263
264pub(crate) fn escaped_name_with_digest(distribution: &str) -> String {
280 let escaped: String = distribution
281 .chars()
282 .map(|character| {
283 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
284 character
285 } else {
286 '_'
287 }
288 })
289 .take(ESCAPED_NAME_BUDGET)
290 .collect();
291 let digest = hex::encode(Sha256::digest(distribution.as_bytes()));
292 format!("{escaped}-{}", &digest[..DIGEST_SUFFIX_LENGTH])
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct InstalledDistribution {
302 name: String,
303 state: String,
304 wsl_version: u8,
305 default: bool,
306}
307
308impl InstalledDistribution {
309 #[must_use]
311 pub fn name(&self) -> &str {
312 &self.name
313 }
314
315 #[must_use]
322 pub fn state(&self) -> &str {
323 &self.state
324 }
325
326 #[must_use]
328 pub fn wsl_version(&self) -> u8 {
329 self.wsl_version
330 }
331
332 #[must_use]
334 pub fn is_default(&self) -> bool {
335 self.default
336 }
337
338 #[must_use]
340 pub fn is_wsl2(&self) -> bool {
341 self.wsl_version == 2
342 }
343
344 pub fn require_wsl2(&self) -> Result<(), WslError> {
350 if self.is_wsl2() {
351 return Ok(());
352 }
353 Err(WslError::NotWsl2 {
354 distribution: self.name.clone(),
355 version: self.wsl_version,
356 })
357 }
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Eq)]
363pub struct DistributionTable {
364 entries: Vec<InstalledDistribution>,
365 unreadable: Vec<String>,
366}
367
368impl DistributionTable {
369 #[must_use]
371 pub fn parse(text: &str) -> Self {
372 let mut entries = Vec::new();
373 let mut unreadable = Vec::new();
374 for line in text.lines() {
375 let line = line.trim_end_matches('\r');
376 if line.trim().is_empty() {
377 continue;
378 }
379 let Some(row) = split_row(line) else {
380 continue;
383 };
384 if validate_distribution_name(&row.name).is_err() {
385 unreadable.push(line.trim().to_string());
386 continue;
387 }
388 entries.push(InstalledDistribution {
389 name: row.name,
390 state: row.state,
391 wsl_version: row.version,
392 default: row.default,
393 });
394 }
395 Self {
396 entries,
397 unreadable,
398 }
399 }
400
401 #[must_use]
403 pub fn from_console_output(bytes: &[u8]) -> Self {
404 Self::parse(decode_console_output(bytes).text())
405 }
406
407 #[must_use]
409 pub fn entries(&self) -> &[InstalledDistribution] {
410 &self.entries
411 }
412
413 #[must_use]
419 pub fn unreadable(&self) -> &[String] {
420 &self.unreadable
421 }
422
423 #[must_use]
425 pub fn is_empty(&self) -> bool {
426 self.entries.is_empty()
427 }
428
429 #[must_use]
431 pub fn default_distribution(&self) -> Option<&InstalledDistribution> {
432 self.entries.iter().find(|entry| entry.default)
433 }
434
435 #[must_use]
437 pub fn names(&self) -> Vec<String> {
438 self.entries
439 .iter()
440 .map(|entry| entry.name.clone())
441 .collect()
442 }
443
444 pub fn exactly(&self, name: &str) -> Result<&InstalledDistribution, WslError> {
457 validate_distribution_name(name)?;
458 let mut found = self.entries.iter().filter(|entry| entry.name == name);
459 let Some(first) = found.next() else {
460 return Err(WslError::NotInstalled {
461 requested: name.to_string(),
462 available: self.names(),
463 });
464 };
465 if found.next().is_some() {
466 return Err(WslError::AmbiguousName {
467 requested: name.to_string(),
468 });
469 }
470 Ok(first)
471 }
472}
473
474struct Row {
476 name: String,
477 state: String,
478 version: u8,
479 default: bool,
480}
481
482fn split_row(line: &str) -> Option<Row> {
484 let trimmed = line.trim_end();
485 let without_marker = trimmed.trim_start();
486 let (default, rest) = match without_marker.strip_prefix('*') {
487 Some(rest) => (true, rest.trim_start()),
488 None => (false, without_marker),
489 };
490
491 let version_at = rest.rfind(char::is_whitespace)? + 1;
492 let version: u8 = rest.get(version_at..)?.parse().ok()?;
493
494 let before_version = rest.get(..version_at)?.trim_end();
495 let state_at = before_version.rfind(char::is_whitespace)? + 1;
496 let state = before_version.get(state_at..)?;
497 if state.is_empty() {
498 return None;
499 }
500
501 let name = before_version.get(..state_at)?.trim_end();
502 if name.is_empty() {
503 return None;
504 }
505
506 Some(Row {
507 name: name.to_string(),
508 state: state.to_string(),
509 version,
510 default,
511 })
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn utf16le(text: &str, bom: bool) -> Vec<u8> {
521 let mut bytes = if bom { vec![0xFF, 0xFE] } else { Vec::new() };
522 for unit in text.encode_utf16() {
523 bytes.extend_from_slice(&unit.to_le_bytes());
524 }
525 bytes
526 }
527
528 const TABLE: &str = concat!(
529 " NAME STATE VERSION\n",
530 "* Ubuntu Running 2\n",
531 " Debian GNU/Linux 12 Stopped 2\n",
532 " Legacy Stopped 1\n",
533 );
534
535 #[test]
538 fn utf16_with_a_byte_order_mark_is_decoded_as_utf16() {
539 let decoded = decode_console_output(&utf16le(TABLE, true));
540 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
541 assert!(!decoded.is_lossy());
542 assert_eq!(decoded.text(), TABLE);
543 }
544
545 #[test]
546 fn utf16_without_a_byte_order_mark_is_recognised_from_its_nul_bytes() {
547 let decoded = decode_console_output(&utf16le(TABLE, false));
548 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
549 assert_eq!(decoded.text(), TABLE);
550 }
551
552 #[test]
553 fn plain_utf8_is_left_alone() {
554 let decoded = decode_console_output(TABLE.as_bytes());
555 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
556 assert!(!decoded.is_lossy());
557 assert_eq!(decoded.text(), TABLE);
558 }
559
560 #[test]
561 fn a_utf8_byte_order_mark_is_removed_rather_than_kept_as_a_character() {
562 let mut bytes = vec![0xEF, 0xBB, 0xBF];
563 bytes.extend_from_slice(TABLE.as_bytes());
564 let decoded = decode_console_output(&bytes);
565 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
566 assert_eq!(decoded.text(), TABLE);
567 }
568
569 #[test]
570 fn a_non_latin_name_is_still_recognised_as_utf16() {
571 let table = concat!(
575 " NAME STATE VERSION\n",
576 "* Убунту Running 2\n",
577 );
578 let decoded = decode_console_output(&utf16le(table, false));
579 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
580 assert_eq!(decoded.text(), table);
581 assert_eq!(DistributionTable::parse(decoded.text()).names(), ["Убунту"]);
582 }
583
584 #[test]
585 fn malformed_utf8_is_kept_lossily_and_says_so() {
586 let bytes = b" Ubuntu \xFF\xFE\xFD Running 2\n".to_vec();
587 let decoded = decode_console_output(&bytes);
588 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
589 assert!(decoded.is_lossy());
590 assert!(decoded.text().contains(char::REPLACEMENT_CHARACTER));
591 }
592
593 #[test]
594 fn truncated_utf16_is_lossy_rather_than_padded_into_a_character() {
595 let mut bytes = utf16le("Ubuntu", true);
596 bytes.push(0x41); let decoded = decode_console_output(&bytes);
598 assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
599 assert!(decoded.is_lossy());
600 assert_eq!(decoded.text(), "Ubuntu");
601 }
602
603 #[test]
604 fn an_unpaired_surrogate_decodes_lossily() {
605 let bytes = vec![0xFF, 0xFE, 0x00, 0xD8, 0x41, 0x00];
607 let decoded = decode_console_output(&bytes);
608 assert!(decoded.is_lossy());
609 assert!(decoded.text().contains(char::REPLACEMENT_CHARACTER));
610 }
611
612 #[test]
613 fn empty_output_decodes_to_nothing_rather_than_panicking() {
614 let decoded = decode_console_output(&[]);
615 assert_eq!(decoded.text(), "");
616 assert!(!decoded.is_lossy());
617 }
618
619 #[test]
622 fn the_header_row_is_not_a_distribution() {
623 let table = DistributionTable::parse(TABLE);
624 assert_eq!(table.names(), ["Ubuntu", "Debian GNU/Linux 12", "Legacy"]);
625 assert!(table.unreadable().is_empty());
626 }
627
628 #[test]
629 fn the_default_marker_is_read_and_does_not_become_part_of_the_name() {
630 let table = DistributionTable::parse(TABLE);
631 assert_eq!(
632 table
633 .default_distribution()
634 .map(InstalledDistribution::name),
635 Some("Ubuntu")
636 );
637 assert!(!table.exactly("Legacy").expect("present").is_default());
638 }
639
640 #[test]
641 fn a_name_with_spaces_and_punctuation_survives_whole() {
642 let table = DistributionTable::parse(TABLE);
643 let debian = table.exactly("Debian GNU/Linux 12").expect("present");
644 assert_eq!(debian.name(), "Debian GNU/Linux 12");
645 assert_eq!(debian.state(), "Stopped");
646 assert!(debian.is_wsl2());
647 }
648
649 #[test]
650 fn a_name_with_two_consecutive_spaces_keeps_both() {
651 let table = DistributionTable::parse(" Two Spaces Running 2\n");
654 assert_eq!(table.names(), ["Two Spaces"]);
655 }
656
657 #[test]
658 fn wsl1_is_listed_and_then_refused_rather_than_hidden() {
659 let table = DistributionTable::parse(TABLE);
660 let legacy = table.exactly("Legacy").expect("it is installed");
661 assert_eq!(legacy.wsl_version(), 1);
662 assert!(!legacy.is_wsl2());
663 let error = legacy.require_wsl2().expect_err("WSL1 is not supported");
664 assert!(
665 matches!(&error, WslError::NotWsl2 { distribution, version } if distribution == "Legacy" && *version == 1),
666 "{error:?}"
667 );
668 let message = error.to_string();
671 assert!(message.contains("Legacy"), "{message}");
672 assert!(
673 message.contains("WSL1") || message.contains(" 1"),
674 "{message}"
675 );
676 }
677
678 #[test]
679 fn a_row_whose_name_did_not_decode_is_reported_rather_than_offered() {
680 let table = DistributionTable::parse(" Ubu\u{FFFD}ntu Running 2\n");
681 assert!(table.is_empty());
682 assert_eq!(table.unreadable().len(), 1);
683 assert!(table.unreadable()[0].contains("Running"));
684 }
685
686 #[test]
687 fn output_with_no_rows_at_all_is_empty_and_not_an_error() {
688 let table = DistributionTable::parse(
689 "Windows Subsystem for Linux has no installed distributions.\n",
690 );
691 assert!(table.is_empty());
692 assert!(table.unreadable().is_empty());
693 }
694
695 #[test]
696 fn a_name_that_is_not_installed_names_what_is() {
697 let table = DistributionTable::parse(TABLE);
698 let error = table
699 .exactly("ubuntu")
700 .expect_err("the list is case-sensitive");
701 let WslError::NotInstalled {
702 requested,
703 available,
704 } = &error
705 else {
706 panic!("unexpected error: {error:?}");
707 };
708 assert_eq!(requested, "ubuntu");
709 assert_eq!(available, &["Ubuntu", "Debian GNU/Linux 12", "Legacy"]);
710 assert!(error.to_string().contains("Ubuntu"));
711 }
712
713 #[test]
714 fn two_rows_with_one_name_refuse_rather_than_pick_one() {
715 let table = DistributionTable::parse(concat!(
716 " Ubuntu Running 2\n",
717 " Ubuntu Stopped 2\n",
718 ));
719 let error = table.exactly("Ubuntu").expect_err("ambiguous");
720 assert!(matches!(error, WslError::AmbiguousName { .. }), "{error:?}");
721 }
722
723 #[test]
724 fn a_future_wsl_version_is_carried_rather_than_clamped() {
725 let table = DistributionTable::parse(" Next Running 3\n");
726 assert_eq!(table.exactly("Next").expect("present").wsl_version(), 3);
727 assert!(
728 table
729 .exactly("Next")
730 .expect("present")
731 .require_wsl2()
732 .is_err(),
733 "only version 2 is supported, and 3 is not 2"
734 );
735 }
736
737 #[test]
740 fn a_name_that_would_read_as_an_option_is_refused() {
741 let error = validate_distribution_name("--shutdown").expect_err("refused");
742 assert!(error.to_string().contains("option"), "{error}");
743 }
744
745 #[test]
746 fn surrounding_whitespace_control_characters_and_emptiness_are_refused() {
747 for name in ["", " ", " Ubuntu", "Ubuntu ", "Ub\nuntu", "Ub\u{0}untu"] {
748 assert!(
749 validate_distribution_name(name).is_err(),
750 "{name:?} should not be accepted"
751 );
752 }
753 }
754
755 #[test]
756 fn ordinary_names_including_shell_metacharacters_are_accepted() {
757 for name in ["Ubuntu", "Ubuntu-24.04", "Debian GNU/Linux 12", "a&b|c;d"] {
762 validate_distribution_name(name)
763 .unwrap_or_else(|error| panic!("{name:?} should be accepted: {error}"));
764 }
765 }
766
767 #[test]
768 fn a_name_longer_than_the_bound_is_refused() {
769 let name = "u".repeat(MAX_DISTRIBUTION_NAME + 1);
770 assert!(validate_distribution_name(&name).is_err());
771 let name = "u".repeat(MAX_DISTRIBUTION_NAME);
772 assert!(validate_distribution_name(&name).is_ok());
773 }
774}