Skip to main content

runner_manager_platform/wsl/
discovery.rs

1// owner: a1-wsl-platform-adapter
2
3//! Reading `wsl.exe --list --verbose`, and deciding that a name an operator
4//! typed is exactly one distribution that is really installed.
5//!
6//! # `wsl.exe` answers in UTF-16, and sometimes does not
7//!
8//! Microsoft documents `wsl --list --verbose` as the way to enumerate
9//! installed distributions and versions
10//! (<https://learn.microsoft.com/en-us/windows/wsl/basic-commands>). What it
11//! does *not* document is the encoding, and it has changed: for most of WSL's
12//! life the output has been UTF-16 little-endian — which through a redirected
13//! pipe reads as ASCII interleaved with NUL bytes — and newer builds have
14//! started emitting plain UTF-8 for some subcommands. A parser that assumes
15//! either one is a parser that reports "no distributions are installed" on the
16//! other, which is the worst possible failure here: it looks exactly like a
17//! machine with no WSL, and the remedy it suggests is to install one.
18//!
19//! So [`decode_console_output`] decides per call, from the bytes:
20//!
21//! | Signal | Read as |
22//! |---|---|
23//! | `FF FE` byte-order mark | UTF-16LE |
24//! | `EF BB BF` byte-order mark | UTF-8 |
25//! | even length, and most odd-indexed bytes are NUL | UTF-16LE |
26//! | anything else | UTF-8 |
27//!
28//! The byte-order mark is authoritative and is checked first. The NUL
29//! heuristic exists for the BOM-less UTF-16 that a redirected `wsl.exe`
30//! produces, and it is deliberately a *majority* test rather than an "any NUL"
31//! test, so that a distribution name in Cyrillic or Japanese — whose UTF-16
32//! high bytes are not NUL — is still recognised as UTF-16 on the strength of
33//! the ASCII around it.
34//!
35//! Anything that does not decode cleanly is kept, with the replacement
36//! character, and [`DecodedOutput::is_lossy`] says so. Nothing here silently
37//! repairs a name: a row whose name did not survive decoding is moved to
38//! [`DistributionTable::unreadable`] rather than offered as something an
39//! operator may install into.
40//!
41//! # A name may contain spaces, so the table is parsed from the right
42//!
43//! ```text
44//!   NAME                   STATE           VERSION
45//! * Ubuntu                 Running         2
46//!   Debian GNU/Linux 12    Stopped         1
47//! ```
48//!
49//! `split_whitespace` would turn the third row's name into four fields. The
50//! last two columns, however, are a single word each, so the row is cut from
51//! the end: the last token is the version, the one before it is the state, and
52//! everything left — trimmed — is the name, spaces, punctuation and all. The
53//! header row falls out of the same rule for free, because `VERSION` does not
54//! parse as a number.
55
56use sha2::{Digest, Sha256};
57
58use super::WslError;
59
60/// How [`decode_console_output`] read the bytes.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ConsoleEncoding {
63    /// UTF-16 little-endian, with or without a byte-order mark.
64    Utf16Le,
65    /// UTF-8, with or without a byte-order mark.
66    Utf8,
67}
68
69/// Console output, decoded, and how it had to be read.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct DecodedOutput {
72    text: String,
73    encoding: ConsoleEncoding,
74    lossy: bool,
75}
76
77impl DecodedOutput {
78    /// The decoded text.
79    #[must_use]
80    pub fn text(&self) -> &str {
81        &self.text
82    }
83
84    /// The decoded text, owned.
85    #[must_use]
86    pub fn into_text(self) -> String {
87        self.text
88    }
89
90    /// Which encoding the bytes were read as.
91    #[must_use]
92    pub fn encoding(&self) -> ConsoleEncoding {
93        self.encoding
94    }
95
96    /// Whether anything had to be replaced to produce the text.
97    ///
98    /// A `true` here is not fatal on its own — a diagnostic sentence with one
99    /// mangled character is still a useful diagnostic — but a *name* that
100    /// carries a replacement character is refused rather than used.
101    #[must_use]
102    pub fn is_lossy(&self) -> bool {
103        self.lossy
104    }
105}
106
107/// The proportion of odd-indexed NUL bytes that reads as UTF-16LE, as a
108/// numerator over [`UTF16_NUL_RATIO_DENOMINATOR`].
109///
110/// Six in ten rather than "any": see the module documentation. A name in a
111/// non-Latin script contributes non-NUL high bytes, and the column padding,
112/// the state words and the version digits around it are ASCII.
113///
114/// Compared as integers rather than as a ratio of `f64`s, so that the decision
115/// is exact for every input length rather than exact for most of them.
116const UTF16_NUL_RATIO_NUMERATOR: usize = 6;
117/// See [`UTF16_NUL_RATIO_NUMERATOR`].
118const UTF16_NUL_RATIO_DENOMINATOR: usize = 10;
119
120/// Decodes what a Windows console program wrote to a pipe.
121#[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
135/// Whether BOM-less bytes are most likely UTF-16LE.
136fn 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    // An odd trailing byte is a truncated stream, not a code unit. It is
155    // dropped and reported as lossy rather than being paired with a zero,
156    // which would invent a character nobody wrote.
157    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
186// ---------------------------------------------------------------------------
187// Names
188// ---------------------------------------------------------------------------
189
190/// The longest distribution name this adapter will handle.
191///
192/// WSL itself imposes no documented limit, but every use here becomes part of
193/// a Task Scheduler name, a file name, and an argument vector. A bound stated
194/// once is better than three different truncations discovered later.
195pub const MAX_DISTRIBUTION_NAME: usize = 255;
196
197/// Checks a name's *syntax*, before it is ever put in an argument vector.
198///
199/// This is not "is it installed" — that is [`DistributionTable::exactly`]. It
200/// is the smaller question of whether the string is safe and meaningful to
201/// pass at all, and it fails closed:
202///
203/// * empty — there is nothing to select;
204/// * leading or trailing whitespace — `"Ubuntu "` and `"Ubuntu"` would look
205///   the same to an operator reading a status line and be different keys. A
206///   name that is *only* whitespace breaks this rule too, so it needs no rule
207///   of its own;
208/// * a control character or a NUL — neither can survive an argument vector or
209///   a task document intact;
210/// * a leading `-` — the one genuinely dangerous shape. `wsl.exe
211///   --distribution --shutdown` is a name that reads as an option, and the
212///   whole argument-vector discipline in this module would not save it.
213///
214/// # Errors
215///
216/// [`WslError::InvalidName`] naming which rule was broken.
217pub 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
257/// How many characters of the escaped distribution name go into an identifier
258/// derived from it.
259pub(crate) const ESCAPED_NAME_BUDGET: usize = 48;
260
261/// How many hex characters of the name's SHA-256 are appended to it.
262pub(crate) const DIGEST_SUFFIX_LENGTH: usize = 8;
263
264/// A distribution name turned into an identifier that a file system and Task
265/// Scheduler will both accept: readable, bounded, and injective.
266///
267/// Both halves are load-bearing. Task Scheduler refuses `\ / : * ? " < > |` in
268/// a name and a file name may hold neither those nor `..`, while a
269/// distribution may legitimately contain several of them — `Debian GNU/Linux
270/// 12` does. Escaping alone would map `Debian GNU/Linux` and `Debian GNU:Linux`
271/// onto one identifier, which is two distributions quietly sharing one task and
272/// one record; the digest suffix is what makes the mapping injective, and the
273/// escaped prefix is what makes it readable in `taskschd.msc` and in a
274/// directory listing.
275///
276/// One function rather than one per caller: [`super::task`] and
277/// [`super::record`] must agree about which distribution an identifier belongs
278/// to, and two copies of this rule is how they would stop agreeing.
279pub(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// ---------------------------------------------------------------------------
296// The table
297// ---------------------------------------------------------------------------
298
299/// One row of `wsl --list --verbose`.
300#[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    /// The exact name, as WSL spells it.
310    #[must_use]
311    pub fn name(&self) -> &str {
312        &self.name
313    }
314
315    /// The state word WSL printed, verbatim.
316    ///
317    /// **Localised.** `Running` and `Stopped` are English; a Windows in
318    /// another display language prints its own words. Nothing in this adapter
319    /// branches on it — running a command starts a stopped distribution
320    /// anyway — so it is carried for display and for nothing else.
321    #[must_use]
322    pub fn state(&self) -> &str {
323        &self.state
324    }
325
326    /// 1 or 2. A larger number from a future WSL is carried, not clamped.
327    #[must_use]
328    pub fn wsl_version(&self) -> u8 {
329        self.wsl_version
330    }
331
332    /// Whether WSL marked this row with `*`.
333    #[must_use]
334    pub fn is_default(&self) -> bool {
335        self.default
336    }
337
338    /// Whether this is a WSL2 distribution, which is the only kind supported.
339    #[must_use]
340    pub fn is_wsl2(&self) -> bool {
341        self.wsl_version == 2
342    }
343
344    /// Refuses anything that is not WSL2.
345    ///
346    /// # Errors
347    ///
348    /// [`WslError::NotWsl2`].
349    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/// Everything `wsl --list --verbose` reported, and everything it reported that
361/// could not be read.
362#[derive(Debug, Clone, Default, PartialEq, Eq)]
363pub struct DistributionTable {
364    entries: Vec<InstalledDistribution>,
365    unreadable: Vec<String>,
366}
367
368impl DistributionTable {
369    /// Parses the decoded table.
370    #[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                // The header, and any banner WSL decides to print, land here
381                // and are simply not rows. They are not reported as damage.
382                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    /// Parses raw console bytes, choosing the encoding.
402    #[must_use]
403    pub fn from_console_output(bytes: &[u8]) -> Self {
404        Self::parse(decode_console_output(bytes).text())
405    }
406
407    /// Every row that parsed.
408    #[must_use]
409    pub fn entries(&self) -> &[InstalledDistribution] {
410        &self.entries
411    }
412
413    /// Rows that looked like rows and whose name did not survive decoding.
414    ///
415    /// Reported rather than dropped: an operator whose distribution is missing
416    /// from `wsl list` deserves to be told that a row was unreadable, not that
417    /// nothing is installed.
418    #[must_use]
419    pub fn unreadable(&self) -> &[String] {
420        &self.unreadable
421    }
422
423    /// Whether nothing at all parsed.
424    #[must_use]
425    pub fn is_empty(&self) -> bool {
426        self.entries.is_empty()
427    }
428
429    /// The row WSL marked with `*`, if any.
430    #[must_use]
431    pub fn default_distribution(&self) -> Option<&InstalledDistribution> {
432        self.entries.iter().find(|entry| entry.default)
433    }
434
435    /// The names that parsed, in order, for an error that has to list them.
436    #[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    /// The one row whose name is exactly `name`.
445    ///
446    /// Exact and case-sensitive. WSL's own `--distribution` is case-sensitive,
447    /// so accepting `ubuntu` for `Ubuntu` here would produce a provider record
448    /// and a task naming a distribution that `wsl.exe` then cannot select.
449    ///
450    /// # Errors
451    ///
452    /// [`WslError::InvalidName`] when the name is not usable at all;
453    /// [`WslError::NotInstalled`] when nothing matches, listing what is there;
454    /// [`WslError::AmbiguousName`] when two rows carry it, which means the
455    /// table is not something to act on.
456    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
474/// The three fields of one row, before validation.
475struct Row {
476    name: String,
477    state: String,
478    version: u8,
479    default: bool,
480}
481
482/// Cuts a row from the right: version, then state, then everything left.
483fn 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    /// Encodes as `wsl.exe` does through a redirected pipe: UTF-16LE, and by
519    /// default without a byte-order mark.
520    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    // -- Decoding ------------------------------------------------------------
536
537    #[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        // The name's own high bytes are not NUL; the rest of the row's are.
572        // This is the case the "any NUL" test would get right and a "every
573        // high byte is NUL" test would get wrong.
574        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); // half a code unit
597        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        // 0xD800 with no low surrogate: the shape a cut-off UTF-16 stream has.
606        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    // -- Parsing -------------------------------------------------------------
620
621    #[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        // The reason the row is cut by index rather than re-joined from
652        // `split_whitespace`: re-joining would silently rename it.
653        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        // The refusal has to name the distribution and the version an operator
669        // would have to change.
670        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    // -- Names ---------------------------------------------------------------
738
739    #[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        // Accepted because nothing here ever builds a shell command: the name
758        // is one element of an argument vector and the metacharacters are just
759        // characters. Refusing them would be security theatre that stopped an
760        // operator using a distribution WSL is perfectly happy with.
761        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}