1use std::path::{Path, PathBuf};
45
46use chrono::{DateTime, Utc};
47use serde::{Deserialize, Serialize};
48
49use super::WslError;
50use super::discovery::{escaped_name_with_digest, validate_distribution_name};
51use crate::paths::AppPaths;
52
53pub const PROVIDER_RECORD_SCHEMA_VERSION: u32 = 1;
55
56pub const PROVIDER_RECORD_DIR: &str = "wsl-providers";
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct WslProviderRecord {
65 pub schema_version: u32,
67 pub distribution: String,
69 pub task_name: String,
71 pub installed_version: String,
73 pub last_verified: DateTime<Utc>,
75}
76
77impl WslProviderRecord {
78 #[must_use]
80 pub fn new(
81 distribution: impl Into<String>,
82 task_name: impl Into<String>,
83 installed_version: impl Into<String>,
84 at: DateTime<Utc>,
85 ) -> Self {
86 Self {
87 schema_version: PROVIDER_RECORD_SCHEMA_VERSION,
88 distribution: distribution.into(),
89 task_name: task_name.into(),
90 installed_version: installed_version.into(),
91 last_verified: at,
92 }
93 }
94
95 #[must_use]
97 pub fn directory(paths: &AppPaths) -> PathBuf {
98 paths.config_dir().join(PROVIDER_RECORD_DIR)
99 }
100
101 pub fn path(paths: &AppPaths, distribution: &str) -> Result<PathBuf, WslError> {
111 validate_distribution_name(distribution)?;
112 Ok(Self::directory(paths).join(format!("{}.toml", escaped_name_with_digest(distribution))))
113 }
114
115 pub fn read(paths: &AppPaths, distribution: &str) -> Result<Option<Self>, WslError> {
123 let path = Self::path(paths, distribution)?;
124 Self::read_file(&path)
125 }
126
127 pub fn read_file(path: &Path) -> Result<Option<Self>, WslError> {
133 let text = match std::fs::read_to_string(path) {
134 Ok(text) => text,
135 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
136 Err(error) => {
137 return Err(WslError::Record {
138 operation: "read",
139 path: path.to_path_buf(),
140 detail: error.to_string(),
141 });
142 }
143 };
144 let record: Self = toml::from_str(&text).map_err(|error| WslError::Record {
145 operation: "read",
146 path: path.to_path_buf(),
147 detail: error.to_string(),
148 })?;
149 if record.schema_version != PROVIDER_RECORD_SCHEMA_VERSION {
150 return Err(WslError::RecordSchema {
151 path: path.to_path_buf(),
152 found: record.schema_version,
153 supported: PROVIDER_RECORD_SCHEMA_VERSION,
154 });
155 }
156 Ok(Some(record))
157 }
158
159 pub fn write(&self, paths: &AppPaths) -> Result<(), WslError> {
174 use std::io::Write as _;
175
176 let path = Self::path(paths, &self.distribution)?;
177 let failed = |operation: &'static str, detail: String| WslError::Record {
178 operation,
179 path: path.clone(),
180 detail,
181 };
182 let text =
183 toml::to_string_pretty(self).map_err(|error| failed("encode", error.to_string()))?;
184 let directory = Self::directory(paths);
185 std::fs::create_dir_all(&directory).map_err(|error| failed("write", error.to_string()))?;
186
187 let mut temporary = tempfile::NamedTempFile::new_in(&directory)
188 .map_err(|error| failed("write", error.to_string()))?;
189 temporary
190 .write_all(text.as_bytes())
191 .and_then(|()| temporary.as_file().sync_all())
192 .map_err(|error| failed("write", error.to_string()))?;
193 #[cfg(unix)]
197 {
198 use std::os::unix::fs::PermissionsExt as _;
199
200 temporary
201 .as_file()
202 .set_permissions(std::fs::Permissions::from_mode(0o644))
203 .map_err(|error| failed("write", error.to_string()))?;
204 }
205 temporary
206 .persist(&path)
207 .map(|_| ())
208 .map_err(|error| failed("write", error.error.to_string()))
209 }
210
211 pub fn remove(paths: &AppPaths, distribution: &str) -> Result<bool, WslError> {
221 let path = Self::path(paths, distribution)?;
222 match std::fs::remove_file(&path) {
223 Ok(()) => Ok(true),
224 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
225 Err(error) => Err(WslError::Record {
226 operation: "remove",
227 path,
228 detail: error.to_string(),
229 }),
230 }
231 }
232
233 pub fn all(paths: &AppPaths) -> Result<Vec<Self>, WslError> {
243 let directory = Self::directory(paths);
244 let entries = match std::fs::read_dir(&directory) {
245 Ok(entries) => entries,
246 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
247 Err(error) => {
248 return Err(WslError::Record {
249 operation: "read",
250 path: directory,
251 detail: error.to_string(),
252 });
253 }
254 };
255 let mut paths_found = Vec::new();
256 for entry in entries {
257 let entry = entry.map_err(|error| WslError::Record {
258 operation: "read",
259 path: directory.clone(),
260 detail: error.to_string(),
261 })?;
262 let path = entry.path();
263 if path
264 .extension()
265 .is_some_and(|extension| extension == "toml")
266 {
267 paths_found.push(path);
268 }
269 }
270 paths_found.sort();
271 let mut records = Vec::with_capacity(paths_found.len());
272 for path in paths_found {
273 if let Some(record) = Self::read_file(&path)? {
274 records.push(record);
275 }
276 }
277 Ok(records)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn at() -> DateTime<Utc> {
286 DateTime::parse_from_rfc3339("2026-09-06T12:00:00Z")
287 .expect("a fixed instant")
288 .with_timezone(&Utc)
289 }
290
291 fn record(distribution: &str) -> WslProviderRecord {
292 WslProviderRecord::new(
293 distribution,
294 "runner-manager-wsl-Ubuntu-1234abcd",
295 "0.4.0",
296 at(),
297 )
298 }
299
300 fn paths() -> (tempfile::TempDir, AppPaths) {
301 let root = tempfile::tempdir().expect("a temporary directory");
302 let paths = AppPaths::rooted_at(root.path());
303 (root, paths)
304 }
305
306 #[test]
309 fn a_record_holds_five_non_secret_facts_and_nothing_else() {
310 let text = toml::to_string_pretty(&record("Ubuntu")).expect("encodable");
311 let keys: Vec<&str> = text
312 .lines()
313 .filter_map(|line| line.split_once(" = "))
314 .map(|(key, _)| key.trim())
315 .collect();
316 assert_eq!(
317 keys,
318 [
319 "schema_version",
320 "distribution",
321 "task_name",
322 "installed_version",
323 "last_verified",
324 ]
325 );
326 }
327
328 #[test]
329 fn a_record_never_mentions_a_credential_a_policy_or_a_jit_configuration() {
330 let text = toml::to_string_pretty(&record("Ubuntu"))
331 .expect("encodable")
332 .to_ascii_lowercase();
333 for forbidden in [
334 "token",
335 "secret",
336 "credential",
337 "refresh",
338 "jit",
339 "policy",
340 "password",
341 "ghu_",
342 ] {
343 assert!(
344 !text.contains(forbidden),
345 "the record mentions {forbidden:?}: {text}"
346 );
347 }
348 }
349
350 #[test]
351 fn a_record_carrying_a_credential_field_is_refused_rather_than_ignored() {
352 let document = concat!(
356 "schema_version = 1\n",
357 "distribution = \"Ubuntu\"\n",
358 "task_name = \"runner-manager-wsl-Ubuntu-1234abcd\"\n",
359 "installed_version = \"0.4.0\"\n",
360 "last_verified = \"2026-09-06T12:00:00Z\"\n",
361 "access_token = \"ghu_notARealCredential\"\n",
362 );
363 let error = toml::from_str::<WslProviderRecord>(document)
364 .expect_err("an unknown field must be refused");
365 assert!(error.to_string().contains("access_token"), "{error}");
366 }
367
368 #[test]
371 fn the_record_lives_under_the_config_directory_and_nowhere_else() {
372 let (root, paths) = paths();
373 let path = WslProviderRecord::path(&paths, "Ubuntu").expect("a valid name");
374 assert!(path.starts_with(paths.config_dir()), "{}", path.display());
375 assert!(
376 path.parent()
377 .is_some_and(|parent| parent.ends_with(PROVIDER_RECORD_DIR))
378 );
379 assert!(path.to_string_lossy().contains("Ubuntu"));
380 drop(root);
381 }
382
383 #[test]
384 fn two_distributions_whose_names_escape_alike_do_not_share_a_file() {
385 let (root, paths) = paths();
386 let first = WslProviderRecord::path(&paths, "Debian GNU/Linux").expect("valid");
387 let second = WslProviderRecord::path(&paths, "Debian GNU:Linux").expect("valid");
388 assert_ne!(first, second);
389 drop(root);
390 }
391
392 #[test]
393 fn a_distribution_name_that_is_not_usable_never_becomes_a_path() {
394 let (root, paths) = paths();
395 assert!(WslProviderRecord::path(&paths, "").is_err());
396 assert!(WslProviderRecord::path(&paths, "--shutdown").is_err());
397 assert!(WslProviderRecord::path(&paths, "Ub\u{0}untu").is_err());
398 drop(root);
399 }
400
401 #[test]
402 fn a_name_full_of_path_syntax_still_lands_inside_the_record_directory() {
403 let (root, paths) = paths();
409 for hostile in [
410 "../../escape",
411 "..",
412 r"C:\Windows\System32",
413 "a/b/c",
414 "Debian GNU/Linux 12",
415 ] {
416 let path = WslProviderRecord::path(&paths, hostile)
417 .unwrap_or_else(|error| panic!("{hostile:?} is a legal WSL name: {error}"));
418 assert_eq!(
419 path.parent(),
420 Some(WslProviderRecord::directory(&paths).as_path()),
421 "{hostile:?} escaped the record directory: {}",
422 path.display()
423 );
424 let stem = path
425 .file_stem()
426 .expect("a file name")
427 .to_string_lossy()
428 .into_owned();
429 assert!(
430 stem.chars()
431 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
432 "{hostile:?} left path syntax in the file name {stem}"
433 );
434 }
435 drop(root);
436 }
437
438 #[test]
439 fn a_written_record_reads_back_exactly() {
440 let (root, paths) = paths();
441 let written = record("Ubuntu");
442 written.write(&paths).expect("written");
443 let read = WslProviderRecord::read(&paths, "Ubuntu")
444 .expect("readable")
445 .expect("present");
446 assert_eq!(read, written);
447 drop(root);
448 }
449
450 #[test]
451 fn a_missing_record_is_absence_rather_than_an_error() {
452 let (root, paths) = paths();
453 assert_eq!(
454 WslProviderRecord::read(&paths, "Ubuntu").expect("no error"),
455 None
456 );
457 drop(root);
458 }
459
460 #[test]
461 fn writing_twice_replaces_and_leaves_no_temporary_file_behind() {
462 let (root, paths) = paths();
463 record("Ubuntu").write(&paths).expect("written");
464 let mut second = record("Ubuntu");
465 second.installed_version = "0.5.0".to_string();
466 second.write(&paths).expect("written again");
467
468 let read = WslProviderRecord::read(&paths, "Ubuntu")
469 .expect("readable")
470 .expect("present");
471 assert_eq!(read.installed_version, "0.5.0");
472
473 let files: Vec<String> = std::fs::read_dir(WslProviderRecord::directory(&paths))
474 .expect("the directory exists")
475 .map(|entry| {
476 entry
477 .expect("readable")
478 .file_name()
479 .to_string_lossy()
480 .into_owned()
481 })
482 .collect();
483 assert_eq!(
484 files.len(),
485 1,
486 "an atomic write leaves exactly the record behind: {files:?}"
487 );
488 assert!(files[0].ends_with(".toml"), "{files:?}");
489 drop(root);
490 }
491
492 #[test]
493 fn a_partly_written_file_is_never_what_a_reader_sees() {
494 let (root, paths) = paths();
499 let directory = WslProviderRecord::directory(&paths);
500 std::fs::create_dir_all(&directory).expect("create");
501 let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
502 assert!(!path.exists());
503 record("Ubuntu").write(&paths).expect("written");
504 assert!(path.exists());
505 let text = std::fs::read_to_string(&path).expect("readable");
506 assert!(text.ends_with('\n'), "the document is complete: {text:?}");
507 toml::from_str::<WslProviderRecord>(&text).expect("and parses");
508 drop(root);
509 }
510
511 #[test]
512 fn removing_a_record_reports_whether_there_was_one_and_removes_nothing_else() {
513 let (root, paths) = paths();
514 record("Ubuntu").write(&paths).expect("written");
515 record("Debian GNU/Linux 12")
516 .write(&paths)
517 .expect("written");
518
519 assert!(WslProviderRecord::remove(&paths, "Ubuntu").expect("removed"));
520 assert!(!WslProviderRecord::remove(&paths, "Ubuntu").expect("already gone"));
521 assert!(
522 WslProviderRecord::read(&paths, "Debian GNU/Linux 12")
523 .expect("readable")
524 .is_some(),
525 "detaching one distribution must not remove another's record"
526 );
527 drop(root);
528 }
529
530 #[test]
531 fn listing_returns_every_record_and_nothing_when_there_are_none() {
532 let (root, paths) = paths();
533 assert!(
534 WslProviderRecord::all(&paths)
535 .expect("no directory yet")
536 .is_empty()
537 );
538 record("Ubuntu").write(&paths).expect("written");
539 record("Alpine").write(&paths).expect("written");
540 let all = WslProviderRecord::all(&paths).expect("listed");
541 assert_eq!(all.len(), 2);
542 let names: Vec<&str> = all
543 .iter()
544 .map(|record| record.distribution.as_str())
545 .collect();
546 assert!(
547 names.contains(&"Ubuntu") && names.contains(&"Alpine"),
548 "{names:?}"
549 );
550 drop(root);
551 }
552
553 #[test]
556 fn a_record_from_a_newer_version_is_refused_rather_than_half_read() {
557 let (root, paths) = paths();
558 let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
559 std::fs::create_dir_all(path.parent().expect("a parent")).expect("create");
560 std::fs::write(
561 &path,
562 concat!(
563 "schema_version = 2\n",
564 "distribution = \"Ubuntu\"\n",
565 "task_name = \"t\"\n",
566 "installed_version = \"0.5.0\"\n",
567 "last_verified = \"2026-09-06T12:00:00Z\"\n",
568 ),
569 )
570 .expect("written");
571 let error = WslProviderRecord::read(&paths, "Ubuntu").expect_err("newer schema");
572 let WslError::RecordSchema {
573 found, supported, ..
574 } = &error
575 else {
576 panic!("unexpected error: {error:?}");
577 };
578 assert_eq!(*found, 2);
579 assert_eq!(*supported, PROVIDER_RECORD_SCHEMA_VERSION);
580 drop(root);
581 }
582
583 #[test]
584 fn a_new_record_is_written_at_the_current_schema_version() {
585 assert_eq!(
586 record("Ubuntu").schema_version,
587 PROVIDER_RECORD_SCHEMA_VERSION
588 );
589 }
590
591 #[test]
592 fn a_damaged_record_is_reported_rather_than_skipped() {
593 let (root, paths) = paths();
594 let path = WslProviderRecord::path(&paths, "Ubuntu").expect("valid");
595 std::fs::create_dir_all(path.parent().expect("a parent")).expect("create");
596 std::fs::write(&path, "this is not TOML at all = = =").expect("written");
597 assert!(WslProviderRecord::read(&paths, "Ubuntu").is_err());
598 assert!(WslProviderRecord::all(&paths).is_err());
599 drop(root);
600 }
601}