use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::WslError;
use super::discovery::{escaped_name_with_digest, validate_distribution_name};
use crate::paths::AppPaths;
pub const PROVIDER_RECORD_SCHEMA_VERSION: u32 = 1;
pub const PROVIDER_RECORD_DIR: &str = "wsl-providers";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WslProviderRecord {
pub schema_version: u32,
pub distribution: String,
pub task_name: String,
pub installed_version: String,
pub last_verified: DateTime<Utc>,
}
impl WslProviderRecord {
#[must_use]
pub fn new(
distribution: impl Into<String>,
task_name: impl Into<String>,
installed_version: impl Into<String>,
at: DateTime<Utc>,
) -> Self {
Self {
schema_version: PROVIDER_RECORD_SCHEMA_VERSION,
distribution: distribution.into(),
task_name: task_name.into(),
installed_version: installed_version.into(),
last_verified: at,
}
}
#[must_use]
pub fn directory(paths: &AppPaths) -> PathBuf {
paths.config_dir().join(PROVIDER_RECORD_DIR)
}
pub fn path(paths: &AppPaths, distribution: &str) -> Result<PathBuf, WslError> {
validate_distribution_name(distribution)?;
Ok(Self::directory(paths).join(format!("{}.toml", escaped_name_with_digest(distribution))))
}
pub fn read(paths: &AppPaths, distribution: &str) -> Result<Option<Self>, WslError> {
let path = Self::path(paths, distribution)?;
Self::read_file(&path)
}
pub fn read_file(path: &Path) -> Result<Option<Self>, WslError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(WslError::Record {
operation: "read",
path: path.to_path_buf(),
detail: error.to_string(),
});
}
};
let record: Self = toml::from_str(&text).map_err(|error| WslError::Record {
operation: "read",
path: path.to_path_buf(),
detail: error.to_string(),
})?;
if record.schema_version != PROVIDER_RECORD_SCHEMA_VERSION {
return Err(WslError::RecordSchema {
path: path.to_path_buf(),
found: record.schema_version,
supported: PROVIDER_RECORD_SCHEMA_VERSION,
});
}
Ok(Some(record))
}
pub fn write(&self, paths: &AppPaths) -> Result<(), WslError> {
use std::io::Write as _;
let path = Self::path(paths, &self.distribution)?;
let failed = |operation: &'static str, detail: String| WslError::Record {
operation,
path: path.clone(),
detail,
};
let text =
toml::to_string_pretty(self).map_err(|error| failed("encode", error.to_string()))?;
let directory = Self::directory(paths);
std::fs::create_dir_all(&directory).map_err(|error| failed("write", error.to_string()))?;
let mut temporary = tempfile::NamedTempFile::new_in(&directory)
.map_err(|error| failed("write", error.to_string()))?;
temporary
.write_all(text.as_bytes())
.and_then(|()| temporary.as_file().sync_all())
.map_err(|error| failed("write", error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
temporary
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644))
.map_err(|error| failed("write", error.to_string()))?;
}
temporary
.persist(&path)
.map(|_| ())
.map_err(|error| failed("write", error.error.to_string()))
}
pub fn remove(paths: &AppPaths, distribution: &str) -> Result<bool, WslError> {
let path = Self::path(paths, distribution)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(WslError::Record {
operation: "remove",
path,
detail: error.to_string(),
}),
}
}
pub fn all(paths: &AppPaths) -> Result<Vec<Self>, WslError> {
let directory = Self::directory(paths);
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(WslError::Record {
operation: "read",
path: directory,
detail: error.to_string(),
});
}
};
let mut paths_found = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| WslError::Record {
operation: "read",
path: directory.clone(),
detail: error.to_string(),
})?;
let path = entry.path();
if path
.extension()
.is_some_and(|extension| extension == "toml")
{
paths_found.push(path);
}
}
paths_found.sort();
let mut records = Vec::with_capacity(paths_found.len());
for path in paths_found {
if let Some(record) = Self::read_file(&path)? {
records.push(record);
}
}
Ok(records)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-09-06T12:00:00Z")
.expect("a fixed instant")
.with_timezone(&Utc)
}
fn record(distribution: &str) -> WslProviderRecord {
WslProviderRecord::new(
distribution,
"runner-manager-wsl-Ubuntu-1234abcd",
"0.4.0",
at(),
)
}
fn paths() -> (tempfile::TempDir, AppPaths) {
let root = tempfile::tempdir().expect("a temporary directory");
let paths = AppPaths::rooted_at(root.path());
(root, paths)
}
#[test]
fn a_record_holds_five_non_secret_facts_and_nothing_else() {
let text = toml::to_string_pretty(&record("Ubuntu")).expect("encodable");
let keys: Vec<&str> = text
.lines()
.filter_map(|line| line.split_once(" = "))
.map(|(key, _)| key.trim())
.collect();
assert_eq!(
keys,
[
"schema_version",
"distribution",
"task_name",
"installed_version",
"last_verified",
]
);
}
#[test]
fn a_record_never_mentions_a_credential_a_policy_or_a_jit_configuration() {
let text = toml::to_string_pretty(&record("Ubuntu"))
.expect("encodable")
.to_ascii_lowercase();
for forbidden in [
"token",
"secret",
"credential",
"refresh",
"jit",
"policy",
"password",
"ghu_",
] {
assert!(
!text.contains(forbidden),
"the record mentions {forbidden:?}: {text}"
);
}
}
#[test]
fn a_record_carrying_a_credential_field_is_refused_rather_than_ignored() {
let document = concat!(
"schema_version = 1\n",
"distribution = \"Ubuntu\"\n",
"task_name = \"runner-manager-wsl-Ubuntu-1234abcd\"\n",
"installed_version = \"0.4.0\"\n",
"last_verified = \"2026-09-06T12:00:00Z\"\n",
"access_token = \"ghu_notARealCredential\"\n",
);
let error = toml::from_str::<WslProviderRecord>(document)
.expect_err("an unknown field must be refused");
assert!(error.to_string().contains("access_token"), "{error}");
}
#[test]
fn the_record_lives_under_the_config_directory_and_nowhere_else() {
let (root, paths) = paths();
let path = WslProviderRecord::path(&paths, "Ubuntu").expect("a valid name");
assert!(path.starts_with(paths.config_dir()), "{}", path.display());
assert!(
path.parent()
.is_some_and(|parent| parent.ends_with(PROVIDER_RECORD_DIR))
);
assert!(path.to_string_lossy().contains("Ubuntu"));
drop(root);
}
#[test]
fn two_distributions_whose_names_escape_alike_do_not_share_a_file() {
let (root, paths) = paths();
let first = WslProviderRecord::path(&paths, "Debian GNU/Linux").expect("valid");
let second = WslProviderRecord::path(&paths, "Debian GNU:Linux").expect("valid");
assert_ne!(first, second);
drop(root);
}
#[test]
fn a_distribution_name_that_is_not_usable_never_becomes_a_path() {
let (root, paths) = paths();
assert!(WslProviderRecord::path(&paths, "").is_err());
assert!(WslProviderRecord::path(&paths, "--shutdown").is_err());
assert!(WslProviderRecord::path(&paths, "Ub\u{0}untu").is_err());
drop(root);
}
#[test]
fn a_name_full_of_path_syntax_still_lands_inside_the_record_directory() {
let (root, paths) = paths();
for hostile in [
"../../escape",
"..",
r"C:\Windows\System32",
"a/b/c",
"Debian GNU/Linux 12",
] {
let path = WslProviderRecord::path(&paths, hostile)
.unwrap_or_else(|error| panic!("{hostile:?} is a legal WSL name: {error}"));
assert_eq!(
path.parent(),
Some(WslProviderRecord::directory(&paths).as_path()),
"{hostile:?} escaped the record directory: {}",
path.display()
);
let stem = path
.file_stem()
.expect("a file name")
.to_string_lossy()
.into_owned();
assert!(
stem.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
"{hostile:?} left path syntax in the file name {stem}"
);
}
drop(root);
}
#[test]
fn a_written_record_reads_back_exactly() {
let (root, paths) = paths();
let written = record("Ubuntu");
written.write(&paths).expect("written");
let read = WslProviderRecord::read(&paths, "Ubuntu")
.expect("readable")
.expect("present");
assert_eq!(read, written);
drop(root);
}
#[test]
fn a_missing_record_is_absence_rather_than_an_error() {
let (root, paths) = paths();
assert_eq!(
WslProviderRecord::read(&paths, "Ubuntu").expect("no error"),
None
);
drop(root);
}
#[test]
fn writing_twice_replaces_and_leaves_no_temporary_file_behind() {
let (root, paths) = paths();
record("Ubuntu").write(&paths).expect("written");
let mut second = record("Ubuntu");
second.installed_version = "0.5.0".to_string();
second.write(&paths).expect("written again");
let read = WslProviderRecord::read(&paths, "Ubuntu")
.expect("readable")
.expect("present");
assert_eq!(read.installed_version, "0.5.0");
let files: Vec<String> = std::fs::read_dir(WslProviderRecord::directory(&paths))
.expect("the directory exists")
.map(|entry| {
entry
.expect("readable")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
files.len(),
1,
"an atomic write leaves exactly the record behind: {files:?}"
);
assert!(files[0].ends_with(".toml"), "{files:?}");
drop(root);
}
#[test]
fn a_partly_written_file_is_never_what_a_reader_sees() {
let (root, paths) = paths();
let directory = WslProviderRecord::directory(&paths);
std::fs::create_dir_all(&directory).expect("create");
let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
assert!(!path.exists());
record("Ubuntu").write(&paths).expect("written");
assert!(path.exists());
let text = std::fs::read_to_string(&path).expect("readable");
assert!(text.ends_with('\n'), "the document is complete: {text:?}");
toml::from_str::<WslProviderRecord>(&text).expect("and parses");
drop(root);
}
#[test]
fn removing_a_record_reports_whether_there_was_one_and_removes_nothing_else() {
let (root, paths) = paths();
record("Ubuntu").write(&paths).expect("written");
record("Debian GNU/Linux 12")
.write(&paths)
.expect("written");
assert!(WslProviderRecord::remove(&paths, "Ubuntu").expect("removed"));
assert!(!WslProviderRecord::remove(&paths, "Ubuntu").expect("already gone"));
assert!(
WslProviderRecord::read(&paths, "Debian GNU/Linux 12")
.expect("readable")
.is_some(),
"detaching one distribution must not remove another's record"
);
drop(root);
}
#[test]
fn listing_returns_every_record_and_nothing_when_there_are_none() {
let (root, paths) = paths();
assert!(
WslProviderRecord::all(&paths)
.expect("no directory yet")
.is_empty()
);
record("Ubuntu").write(&paths).expect("written");
record("Alpine").write(&paths).expect("written");
let all = WslProviderRecord::all(&paths).expect("listed");
assert_eq!(all.len(), 2);
let names: Vec<&str> = all
.iter()
.map(|record| record.distribution.as_str())
.collect();
assert!(
names.contains(&"Ubuntu") && names.contains(&"Alpine"),
"{names:?}"
);
drop(root);
}
#[test]
fn a_record_from_a_newer_version_is_refused_rather_than_half_read() {
let (root, paths) = paths();
let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
std::fs::create_dir_all(path.parent().expect("a parent")).expect("create");
std::fs::write(
&path,
concat!(
"schema_version = 2\n",
"distribution = \"Ubuntu\"\n",
"task_name = \"t\"\n",
"installed_version = \"0.5.0\"\n",
"last_verified = \"2026-09-06T12:00:00Z\"\n",
),
)
.expect("written");
let error = WslProviderRecord::read(&paths, "Ubuntu").expect_err("newer schema");
let WslError::RecordSchema {
found, supported, ..
} = &error
else {
panic!("unexpected error: {error:?}");
};
assert_eq!(*found, 2);
assert_eq!(*supported, PROVIDER_RECORD_SCHEMA_VERSION);
drop(root);
}
#[test]
fn a_new_record_is_written_at_the_current_schema_version() {
assert_eq!(
record("Ubuntu").schema_version,
PROVIDER_RECORD_SCHEMA_VERSION
);
}
#[test]
fn a_damaged_record_is_reported_rather_than_skipped() {
let (root, paths) = paths();
let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
std::fs::create_dir_all(path.parent().expect("a parent")).expect("create");
std::fs::write(&path, "this is not TOML at all = = =").expect("written");
assert!(WslProviderRecord::read(&paths, "Ubuntu").is_err());
assert!(WslProviderRecord::all(&paths).is_err());
drop(root);
}
}