Skip to main content

runner_manager_platform/wsl/
record.rs

1// owner: a1-wsl-platform-adapter
2
3//! The one non-secret file this feature writes on the Windows side: a record
4//! per managed WSL distribution.
5//!
6//! # It is advisory, and being advisory is what makes it safe
7//!
8//! `02-target-architecture.md` is explicit: *"the record is advisory: status
9//! verifies actual WSL/service state and reports drift. A missing record never
10//! licenses deletion inside a distribution."* Everything in the shape of this
11//! type follows from that sentence.
12//!
13//! Nothing here is a source of truth about the distribution. `wsl status` asks
14//! WSL whether the distribution is there, asks the Linux service whether it is
15//! healthy, and asks Task Scheduler whether the task exists; the record only
16//! says *"this workstation believes it manages this one, and here is what it
17//! last installed"*. So a record that is stale, hand-edited, or absent
18//! degrades a status line and can never cause a deletion.
19//!
20//! # What it may not contain
21//!
22//! `03-security-and-lifecycle.md` item 3 lists provider records among the
23//! places the credential document must be absent from, and
24//! `02-target-architecture.md` adds GitHub JIT configuration and repository
25//! policies. Two things enforce that rather than one:
26//!
27//! * the struct has five fields and none of them could hold a secret; and
28//! * it is `#[serde(deny_unknown_fields)]`, so a document that grew a `token`
29//!   key — by a hand edit, or by a future version writing one — fails to parse
30//!   instead of being read and re-written.
31//!
32//! The second is the one that matters over time. A field nobody added cannot
33//! leak; a field somebody adds later is caught by
34//! `a_record_carrying_a_credential_field_is_refused_rather_than_ignored`.
35//!
36//! # Schema version
37//!
38//! [`PROVIDER_RECORD_SCHEMA_VERSION`] is written and checked. A record from a
39//! *newer* version is refused rather than read on a best-effort basis: this
40//! product supports downgrades through `update`, and a 0.4 binary silently
41//! half-reading a 0.5 record — then rewriting it, dropping whatever it did not
42//! understand — is how the newer install loses state.
43
44use 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
53/// The schema this version writes and is willing to read.
54pub const PROVIDER_RECORD_SCHEMA_VERSION: u32 = 1;
55
56/// The directory, under the config directory, that holds the records.
57pub const PROVIDER_RECORD_DIR: &str = "wsl-providers";
58
59/// One managed WSL distribution, as this workstation last saw it.
60///
61/// Every field is non-secret and every field is checkable against reality.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct WslProviderRecord {
65    /// The schema this document was written under.
66    pub schema_version: u32,
67    /// The exact distribution name, as `wsl --list` spells it.
68    pub distribution: String,
69    /// The Task Scheduler name of the lifecycle task.
70    pub task_name: String,
71    /// The runner-manager version last installed inside the distribution.
72    pub installed_version: String,
73    /// When the provider last verified the distribution's actual state.
74    pub last_verified: DateTime<Utc>,
75}
76
77impl WslProviderRecord {
78    /// A record for a distribution this workstation now manages.
79    #[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    /// The directory the records live in.
96    #[must_use]
97    pub fn directory(paths: &AppPaths) -> PathBuf {
98        paths.config_dir().join(PROVIDER_RECORD_DIR)
99    }
100
101    /// Where one distribution's record lives.
102    ///
103    /// The file name is derived the same way the task name is, by the same
104    /// function — [`super::discovery::escaped_name_with_digest`] — so two
105    /// distributions whose names escape alike cannot share a file.
106    ///
107    /// # Errors
108    ///
109    /// [`WslError::InvalidName`] for a distribution name that cannot be used.
110    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    /// Reads a distribution's record, if there is one.
116    ///
117    /// # Errors
118    ///
119    /// [`WslError::InvalidName`]; [`WslError::Record`] when the file exists
120    /// and cannot be read or parsed; [`WslError::RecordSchema`] when it was
121    /// written by a version this one does not understand.
122    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    /// Reads one record file.
128    ///
129    /// # Errors
130    ///
131    /// As [`WslProviderRecord::read`].
132    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    /// Writes the record, replacing any previous one atomically.
160    ///
161    /// # Atomic, and why it has to be
162    ///
163    /// The document is written to a temporary file *in the same directory*,
164    /// flushed to disk, and renamed onto its destination — so a reader either
165    /// sees the previous record or the new one, never a half-written one. A
166    /// truncated record is not a cosmetic problem: it is what
167    /// [`WslProviderRecord::read`] would refuse to parse, on a machine whose
168    /// provisioning had in fact succeeded.
169    ///
170    /// # Errors
171    ///
172    /// [`WslError::InvalidName`] and [`WslError::Record`].
173    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        // `0644` for the same reason `service::InstallRecord` gives: this is
194        // non-secret TOML in a `0700` directory, and a `0600` file written
195        // under `sudo` becomes one the operator's own `status` cannot read.
196        #[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    /// Removes a distribution's record. Returns whether there was one.
212    ///
213    /// This is the whole of what `wsl detach` deletes on the Windows side
214    /// besides the task: nothing inside the distribution, and nothing else in
215    /// the config directory.
216    ///
217    /// # Errors
218    ///
219    /// [`WslError::InvalidName`] and [`WslError::Record`].
220    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    /// Every record this workstation holds, in file-name order.
234    ///
235    /// A file that does not parse is reported rather than skipped: a config
236    /// directory with a damaged record is something an operator should be told
237    /// about, not something `wsl list` should quietly show one fewer row for.
238    ///
239    /// # Errors
240    ///
241    /// [`WslError::Record`] or [`WslError::RecordSchema`].
242    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    // -- Shape ---------------------------------------------------------------
307
308    #[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        // `deny_unknown_fields` is the control that survives a future edit:
353        // a version that started writing a token here would fail this crate's
354        // own reader rather than round-trip it.
355        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    // -- Files ---------------------------------------------------------------
369
370    #[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        // `..`, `/` and `\` are all legal in a WSL distribution name -- WSL
404        // itself is happy with `Debian GNU/Linux 12` -- so a file name built
405        // from one naively would write outside the record directory. The
406        // escaping in `escaped_name_with_digest` is what stops that, and this
407        // is the property it exists for.
408        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        // The property the temporary-file-plus-rename buys, asserted the only
495        // way it can be from one thread: the destination does not exist until
496        // it is complete, so a reader that runs before the rename sees
497        // absence, and one that runs after sees the whole document.
498        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    // -- Schema --------------------------------------------------------------
554
555    #[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}