use sha2::{Digest, Sha256};
use super::WslError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsoleEncoding {
Utf16Le,
Utf8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedOutput {
text: String,
encoding: ConsoleEncoding,
lossy: bool,
}
impl DecodedOutput {
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_text(self) -> String {
self.text
}
#[must_use]
pub fn encoding(&self) -> ConsoleEncoding {
self.encoding
}
#[must_use]
pub fn is_lossy(&self) -> bool {
self.lossy
}
}
const UTF16_NUL_RATIO_NUMERATOR: usize = 6;
const UTF16_NUL_RATIO_DENOMINATOR: usize = 10;
#[must_use]
pub fn decode_console_output(bytes: &[u8]) -> DecodedOutput {
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
return decode_utf16le(rest);
}
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return decode_utf8(rest);
}
if looks_like_utf16le(bytes) {
return decode_utf16le(bytes);
}
decode_utf8(bytes)
}
fn looks_like_utf16le(bytes: &[u8]) -> bool {
if bytes.len() < 2 || !bytes.len().is_multiple_of(2) {
return false;
}
let pairs = bytes.len() / 2;
let nul_high_bytes = bytes
.iter()
.skip(1)
.step_by(2)
.filter(|byte| **byte == 0)
.count();
if nul_high_bytes == 0 {
return false;
}
nul_high_bytes * UTF16_NUL_RATIO_DENOMINATOR >= pairs * UTF16_NUL_RATIO_NUMERATOR
}
fn decode_utf16le(bytes: &[u8]) -> DecodedOutput {
let truncated = !bytes.len().is_multiple_of(2);
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
let text = String::from_utf16_lossy(&units);
let lossy = truncated || text.contains(char::REPLACEMENT_CHARACTER);
DecodedOutput {
text,
encoding: ConsoleEncoding::Utf16Le,
lossy,
}
}
fn decode_utf8(bytes: &[u8]) -> DecodedOutput {
match std::str::from_utf8(bytes) {
Ok(text) => DecodedOutput {
text: text.to_string(),
encoding: ConsoleEncoding::Utf8,
lossy: false,
},
Err(_) => DecodedOutput {
text: String::from_utf8_lossy(bytes).into_owned(),
encoding: ConsoleEncoding::Utf8,
lossy: true,
},
}
}
pub const MAX_DISTRIBUTION_NAME: usize = 255;
pub fn validate_distribution_name(name: &str) -> Result<(), WslError> {
let refuse = |reason: &str| {
Err(WslError::InvalidName {
requested: name.to_string(),
reason: reason.to_string(),
})
};
if name.is_empty() {
return refuse("it is empty, so it names no distribution");
}
if name.trim() != name {
return refuse(
"it starts or ends with whitespace, which no `wsl --list` row reports and which \
would make two different names print identically",
);
}
if name.chars().count() > MAX_DISTRIBUTION_NAME {
return refuse("it is longer than a distribution name may be here");
}
if name.chars().any(char::is_control) {
return refuse(
"it contains a control character, which cannot survive an argument vector or a \
scheduled-task document intact",
);
}
if name.starts_with('-') {
return refuse(
"it starts with `-`, so `wsl.exe` would read it as an option rather than as the \
distribution to select",
);
}
if name.contains(char::REPLACEMENT_CHARACTER) {
return refuse(
"it contains a Unicode replacement character, which means it was already damaged \
by a decoding step and is not the name of anything",
);
}
Ok(())
}
pub(crate) const ESCAPED_NAME_BUDGET: usize = 48;
pub(crate) const DIGEST_SUFFIX_LENGTH: usize = 8;
pub(crate) fn escaped_name_with_digest(distribution: &str) -> String {
let escaped: String = distribution
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
character
} else {
'_'
}
})
.take(ESCAPED_NAME_BUDGET)
.collect();
let digest = hex::encode(Sha256::digest(distribution.as_bytes()));
format!("{escaped}-{}", &digest[..DIGEST_SUFFIX_LENGTH])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledDistribution {
name: String,
state: String,
wsl_version: u8,
default: bool,
}
impl InstalledDistribution {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn state(&self) -> &str {
&self.state
}
#[must_use]
pub fn wsl_version(&self) -> u8 {
self.wsl_version
}
#[must_use]
pub fn is_default(&self) -> bool {
self.default
}
#[must_use]
pub fn is_wsl2(&self) -> bool {
self.wsl_version == 2
}
pub fn require_wsl2(&self) -> Result<(), WslError> {
if self.is_wsl2() {
return Ok(());
}
Err(WslError::NotWsl2 {
distribution: self.name.clone(),
version: self.wsl_version,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DistributionTable {
entries: Vec<InstalledDistribution>,
unreadable: Vec<String>,
}
impl DistributionTable {
#[must_use]
pub fn parse(text: &str) -> Self {
let mut entries = Vec::new();
let mut unreadable = Vec::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let Some(row) = split_row(line) else {
continue;
};
if validate_distribution_name(&row.name).is_err() {
unreadable.push(line.trim().to_string());
continue;
}
entries.push(InstalledDistribution {
name: row.name,
state: row.state,
wsl_version: row.version,
default: row.default,
});
}
Self {
entries,
unreadable,
}
}
#[must_use]
pub fn from_console_output(bytes: &[u8]) -> Self {
Self::parse(decode_console_output(bytes).text())
}
#[must_use]
pub fn entries(&self) -> &[InstalledDistribution] {
&self.entries
}
#[must_use]
pub fn unreadable(&self) -> &[String] {
&self.unreadable
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn default_distribution(&self) -> Option<&InstalledDistribution> {
self.entries.iter().find(|entry| entry.default)
}
#[must_use]
pub fn names(&self) -> Vec<String> {
self.entries
.iter()
.map(|entry| entry.name.clone())
.collect()
}
pub fn exactly(&self, name: &str) -> Result<&InstalledDistribution, WslError> {
validate_distribution_name(name)?;
let mut found = self.entries.iter().filter(|entry| entry.name == name);
let Some(first) = found.next() else {
return Err(WslError::NotInstalled {
requested: name.to_string(),
available: self.names(),
});
};
if found.next().is_some() {
return Err(WslError::AmbiguousName {
requested: name.to_string(),
});
}
Ok(first)
}
}
struct Row {
name: String,
state: String,
version: u8,
default: bool,
}
fn split_row(line: &str) -> Option<Row> {
let trimmed = line.trim_end();
let without_marker = trimmed.trim_start();
let (default, rest) = match without_marker.strip_prefix('*') {
Some(rest) => (true, rest.trim_start()),
None => (false, without_marker),
};
let version_at = rest.rfind(char::is_whitespace)? + 1;
let version: u8 = rest.get(version_at..)?.parse().ok()?;
let before_version = rest.get(..version_at)?.trim_end();
let state_at = before_version.rfind(char::is_whitespace)? + 1;
let state = before_version.get(state_at..)?;
if state.is_empty() {
return None;
}
let name = before_version.get(..state_at)?.trim_end();
if name.is_empty() {
return None;
}
Some(Row {
name: name.to_string(),
state: state.to_string(),
version,
default,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn utf16le(text: &str, bom: bool) -> Vec<u8> {
let mut bytes = if bom { vec![0xFF, 0xFE] } else { Vec::new() };
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
const TABLE: &str = concat!(
" NAME STATE VERSION\n",
"* Ubuntu Running 2\n",
" Debian GNU/Linux 12 Stopped 2\n",
" Legacy Stopped 1\n",
);
#[test]
fn utf16_with_a_byte_order_mark_is_decoded_as_utf16() {
let decoded = decode_console_output(&utf16le(TABLE, true));
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
assert!(!decoded.is_lossy());
assert_eq!(decoded.text(), TABLE);
}
#[test]
fn utf16_without_a_byte_order_mark_is_recognised_from_its_nul_bytes() {
let decoded = decode_console_output(&utf16le(TABLE, false));
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
assert_eq!(decoded.text(), TABLE);
}
#[test]
fn plain_utf8_is_left_alone() {
let decoded = decode_console_output(TABLE.as_bytes());
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
assert!(!decoded.is_lossy());
assert_eq!(decoded.text(), TABLE);
}
#[test]
fn a_utf8_byte_order_mark_is_removed_rather_than_kept_as_a_character() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(TABLE.as_bytes());
let decoded = decode_console_output(&bytes);
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
assert_eq!(decoded.text(), TABLE);
}
#[test]
fn a_non_latin_name_is_still_recognised_as_utf16() {
let table = concat!(
" NAME STATE VERSION\n",
"* Убунту Running 2\n",
);
let decoded = decode_console_output(&utf16le(table, false));
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
assert_eq!(decoded.text(), table);
assert_eq!(DistributionTable::parse(decoded.text()).names(), ["Убунту"]);
}
#[test]
fn malformed_utf8_is_kept_lossily_and_says_so() {
let bytes = b" Ubuntu \xFF\xFE\xFD Running 2\n".to_vec();
let decoded = decode_console_output(&bytes);
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf8);
assert!(decoded.is_lossy());
assert!(decoded.text().contains(char::REPLACEMENT_CHARACTER));
}
#[test]
fn truncated_utf16_is_lossy_rather_than_padded_into_a_character() {
let mut bytes = utf16le("Ubuntu", true);
bytes.push(0x41); let decoded = decode_console_output(&bytes);
assert_eq!(decoded.encoding(), ConsoleEncoding::Utf16Le);
assert!(decoded.is_lossy());
assert_eq!(decoded.text(), "Ubuntu");
}
#[test]
fn an_unpaired_surrogate_decodes_lossily() {
let bytes = vec![0xFF, 0xFE, 0x00, 0xD8, 0x41, 0x00];
let decoded = decode_console_output(&bytes);
assert!(decoded.is_lossy());
assert!(decoded.text().contains(char::REPLACEMENT_CHARACTER));
}
#[test]
fn empty_output_decodes_to_nothing_rather_than_panicking() {
let decoded = decode_console_output(&[]);
assert_eq!(decoded.text(), "");
assert!(!decoded.is_lossy());
}
#[test]
fn the_header_row_is_not_a_distribution() {
let table = DistributionTable::parse(TABLE);
assert_eq!(table.names(), ["Ubuntu", "Debian GNU/Linux 12", "Legacy"]);
assert!(table.unreadable().is_empty());
}
#[test]
fn the_default_marker_is_read_and_does_not_become_part_of_the_name() {
let table = DistributionTable::parse(TABLE);
assert_eq!(
table
.default_distribution()
.map(InstalledDistribution::name),
Some("Ubuntu")
);
assert!(!table.exactly("Legacy").expect("present").is_default());
}
#[test]
fn a_name_with_spaces_and_punctuation_survives_whole() {
let table = DistributionTable::parse(TABLE);
let debian = table.exactly("Debian GNU/Linux 12").expect("present");
assert_eq!(debian.name(), "Debian GNU/Linux 12");
assert_eq!(debian.state(), "Stopped");
assert!(debian.is_wsl2());
}
#[test]
fn a_name_with_two_consecutive_spaces_keeps_both() {
let table = DistributionTable::parse(" Two Spaces Running 2\n");
assert_eq!(table.names(), ["Two Spaces"]);
}
#[test]
fn wsl1_is_listed_and_then_refused_rather_than_hidden() {
let table = DistributionTable::parse(TABLE);
let legacy = table.exactly("Legacy").expect("it is installed");
assert_eq!(legacy.wsl_version(), 1);
assert!(!legacy.is_wsl2());
let error = legacy.require_wsl2().expect_err("WSL1 is not supported");
assert!(
matches!(&error, WslError::NotWsl2 { distribution, version } if distribution == "Legacy" && *version == 1),
"{error:?}"
);
let message = error.to_string();
assert!(message.contains("Legacy"), "{message}");
assert!(
message.contains("WSL1") || message.contains(" 1"),
"{message}"
);
}
#[test]
fn a_row_whose_name_did_not_decode_is_reported_rather_than_offered() {
let table = DistributionTable::parse(" Ubu\u{FFFD}ntu Running 2\n");
assert!(table.is_empty());
assert_eq!(table.unreadable().len(), 1);
assert!(table.unreadable()[0].contains("Running"));
}
#[test]
fn output_with_no_rows_at_all_is_empty_and_not_an_error() {
let table = DistributionTable::parse(
"Windows Subsystem for Linux has no installed distributions.\n",
);
assert!(table.is_empty());
assert!(table.unreadable().is_empty());
}
#[test]
fn a_name_that_is_not_installed_names_what_is() {
let table = DistributionTable::parse(TABLE);
let error = table
.exactly("ubuntu")
.expect_err("the list is case-sensitive");
let WslError::NotInstalled {
requested,
available,
} = &error
else {
panic!("unexpected error: {error:?}");
};
assert_eq!(requested, "ubuntu");
assert_eq!(available, &["Ubuntu", "Debian GNU/Linux 12", "Legacy"]);
assert!(error.to_string().contains("Ubuntu"));
}
#[test]
fn two_rows_with_one_name_refuse_rather_than_pick_one() {
let table = DistributionTable::parse(concat!(
" Ubuntu Running 2\n",
" Ubuntu Stopped 2\n",
));
let error = table.exactly("Ubuntu").expect_err("ambiguous");
assert!(matches!(error, WslError::AmbiguousName { .. }), "{error:?}");
}
#[test]
fn a_future_wsl_version_is_carried_rather_than_clamped() {
let table = DistributionTable::parse(" Next Running 3\n");
assert_eq!(table.exactly("Next").expect("present").wsl_version(), 3);
assert!(
table
.exactly("Next")
.expect("present")
.require_wsl2()
.is_err(),
"only version 2 is supported, and 3 is not 2"
);
}
#[test]
fn a_name_that_would_read_as_an_option_is_refused() {
let error = validate_distribution_name("--shutdown").expect_err("refused");
assert!(error.to_string().contains("option"), "{error}");
}
#[test]
fn surrounding_whitespace_control_characters_and_emptiness_are_refused() {
for name in ["", " ", " Ubuntu", "Ubuntu ", "Ub\nuntu", "Ub\u{0}untu"] {
assert!(
validate_distribution_name(name).is_err(),
"{name:?} should not be accepted"
);
}
}
#[test]
fn ordinary_names_including_shell_metacharacters_are_accepted() {
for name in ["Ubuntu", "Ubuntu-24.04", "Debian GNU/Linux 12", "a&b|c;d"] {
validate_distribution_name(name)
.unwrap_or_else(|error| panic!("{name:?} should be accepted: {error}"));
}
}
#[test]
fn a_name_longer_than_the_bound_is_refused() {
let name = "u".repeat(MAX_DISTRIBUTION_NAME + 1);
assert!(validate_distribution_name(&name).is_err());
let name = "u".repeat(MAX_DISTRIBUTION_NAME);
assert!(validate_distribution_name(&name).is_ok());
}
}