Skip to main content

subc_daemon/
machine_id.rs

1//! The machine id file: where the daemon's [`MachineId`] lives on disk, how the
2//! daemon mints it once, and how an operator replaces it.
3//!
4//! The file is `<data home>/cortexkit/machine-id`, one line of 32 lowercase hex
5//! characters. The daemon reads it at startup, before it accepts a connection,
6//! and mints it only when it is absent. It never rewrites a present file: a file
7//! that does not parse stops the daemon with [`MachineIdFileError::Corrupt`],
8//! because silently replacing a corrupt identity would hand every module a new
9//! name for the same machine. Only `ck machine adopt` replaces the value, and it
10//! takes effect at the next daemon start.
11//!
12//! See [`MachineId`] for the rule every holder of the value must follow: it is a
13//! name, never an authority.
14
15use std::{
16    error::Error,
17    fmt, fs,
18    io::{self, Write as _},
19    path::{Path, PathBuf},
20};
21
22pub use subc_protocol::{MachineId, MachineIdError};
23
24/// File name of the machine id under `<data home>/cortexkit/`.
25pub const MACHINE_ID_FILE_NAME: &str = "machine-id";
26
27/// Resolve `<data home>/cortexkit/machine-id` from the same data home the daemon
28/// uses for storage.
29///
30/// A relative data home (HOME and XDG_DATA_HOME both unset) is refused rather
31/// than resolved against the working directory: the machine's identity must not
32/// depend on where the daemon happened to be started from.
33pub fn default_machine_id_path() -> Result<PathBuf, MachineIdFileError> {
34    let data_home = crate::daemon_config::default_data_home();
35    if !data_home.is_absolute() {
36        return Err(MachineIdFileError::RelativeDataHome { data_home });
37    }
38    Ok(data_home.join("cortexkit").join(MACHINE_ID_FILE_NAME))
39}
40
41/// Read the machine id at `path`, minting and writing a fresh one when the file
42/// is absent. A present file is only ever read, never rewritten.
43///
44/// The caller must hold whatever exclusion keeps two daemons from booting at
45/// once (the daemon calls this under its start lock); two concurrent mints
46/// would otherwise race to the rename and the loser's modules would have seen a
47/// value that no longer exists.
48pub fn load_or_mint(path: &Path) -> Result<MachineId, MachineIdFileError> {
49    if let Some(existing) = read(path)? {
50        return Ok(existing);
51    }
52    let mut bytes = [0u8; 16];
53    getrandom::getrandom(&mut bytes).map_err(MachineIdFileError::Random)?;
54    write(path, &MachineId::from_bytes(bytes))?;
55    // Serve what is on disk, not what was generated: if the write landed
56    // anything other than the minted value, the next boot would disagree with
57    // this one, so this boot fails now instead.
58    read(path)?.ok_or_else(|| MachineIdFileError::Write {
59        path: path.to_path_buf(),
60        source: io::Error::new(
61            io::ErrorKind::NotFound,
62            "machine id file vanished right after it was written",
63        ),
64    })
65}
66
67/// Read and validate the machine id at `path`. `Ok(None)` means the file does
68/// not exist; any other read failure, or content that is not exactly 32
69/// lowercase hex characters (one trailing newline allowed), is an error.
70pub fn read(path: &Path) -> Result<Option<MachineId>, MachineIdFileError> {
71    let bytes = match fs::read(path) {
72        Ok(bytes) => bytes,
73        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
74        Err(source) => {
75            return Err(MachineIdFileError::Read {
76                path: path.to_path_buf(),
77                source,
78            })
79        }
80    };
81    let text = String::from_utf8(bytes).map_err(|_| MachineIdFileError::Corrupt {
82        path: path.to_path_buf(),
83        reason: "the file is not UTF-8".to_string(),
84    })?;
85    parse_file_contents(&text)
86        .map(Some)
87        .map_err(|reason| MachineIdFileError::Corrupt {
88            path: path.to_path_buf(),
89            reason: reason.to_string(),
90        })
91}
92
93/// Write `id` to `path` through a temporary file in the same directory and a
94/// rename, so a reader sees either the old file or the complete new one.
95///
96/// This replaces an existing file. The daemon only calls it when the file is
97/// absent; `ck machine adopt` calls it deliberately.
98pub fn write(path: &Path, id: &MachineId) -> Result<(), MachineIdFileError> {
99    let write_error = |source: io::Error| MachineIdFileError::Write {
100        path: path.to_path_buf(),
101        source,
102    };
103    let dir = path.parent().ok_or_else(|| {
104        write_error(io::Error::new(
105            io::ErrorKind::InvalidInput,
106            "machine id path has no parent directory",
107        ))
108    })?;
109    fs::create_dir_all(dir).map_err(write_error)?;
110
111    let mut suffix = [0u8; 8];
112    getrandom::getrandom(&mut suffix).map_err(MachineIdFileError::Random)?;
113    let tmp = dir.join(format!(
114        ".{MACHINE_ID_FILE_NAME}.tmp-{}-{:016x}",
115        std::process::id(),
116        u64::from_be_bytes(suffix)
117    ));
118    let result = (|| {
119        let mut file = fs::OpenOptions::new()
120            .write(true)
121            .create_new(true)
122            .open(&tmp)?;
123        file.write_all(format!("{id}\n").as_bytes())?;
124        file.sync_all()?;
125        drop(file);
126        fs::rename(&tmp, path)
127    })();
128    if let Err(source) = result {
129        let _ = fs::remove_file(&tmp);
130        return Err(write_error(source));
131    }
132    Ok(())
133}
134
135/// Parse file content: the id, optionally followed by one line ending.
136fn parse_file_contents(text: &str) -> Result<MachineId, MachineIdError> {
137    let line = text
138        .strip_suffix("\r\n")
139        .or_else(|| text.strip_suffix('\n'))
140        .unwrap_or(text);
141    MachineId::parse(line)
142}
143
144/// Why the daemon could not establish its machine id.
145#[derive(Debug)]
146pub enum MachineIdFileError {
147    /// The data home resolved to a relative path, so the file's location would
148    /// depend on the working directory.
149    RelativeDataHome { data_home: PathBuf },
150    /// The file exists but does not hold a machine id. The daemon refuses to
151    /// start rather than replace it.
152    Corrupt { path: PathBuf, reason: String },
153    /// The file exists but could not be read.
154    Read { path: PathBuf, source: io::Error },
155    /// A new file could not be written.
156    Write { path: PathBuf, source: io::Error },
157    /// The operating system's random source failed.
158    Random(getrandom::Error),
159}
160
161impl fmt::Display for MachineIdFileError {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::RelativeDataHome { data_home } => write!(
165                f,
166                "machine id: the data home resolved to the relative path {}; set HOME or XDG_DATA_HOME so the machine id has a fixed location",
167                data_home.display()
168            ),
169            Self::Corrupt { path, reason } => write!(
170                f,
171                "machine id file {} is corrupt ({reason}); the daemon will not replace a machine identity. Restore the file from backup, or run `ck machine adopt <id>` with the id this machine had",
172                path.display()
173            ),
174            Self::Read { path, source } => {
175                write!(f, "machine id file {} could not be read: {source}", path.display())
176            }
177            Self::Write { path, source } => write!(
178                f,
179                "machine id file {} could not be written: {source}",
180                path.display()
181            ),
182            Self::Random(source) => {
183                write!(f, "machine id: the random source failed: {source}")
184            }
185        }
186    }
187}
188
189impl Error for MachineIdFileError {
190    fn source(&self) -> Option<&(dyn Error + 'static)> {
191        match self {
192            Self::Read { source, .. } | Self::Write { source, .. } => Some(source),
193            // getrandom's error implements std's Error only with its `std`
194            // feature, which this crate does not enable; Display carries it.
195            Self::Random(_) | Self::RelativeDataHome { .. } | Self::Corrupt { .. } => None,
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn temp_dir(name: &str) -> subc_test_support::TestTempDir {
205        subc_test_support::TestTempDir::new(&format!("subc-machine-id-{name}"))
206    }
207
208    #[test]
209    fn mint_writes_one_line_and_a_second_load_reads_the_same_id() {
210        let dir = temp_dir("mint");
211        let path = dir.join("cortexkit").join(MACHINE_ID_FILE_NAME);
212        let first = load_or_mint(&path).expect("mint");
213        assert_eq!(fs::read_to_string(&path).unwrap(), format!("{first}\n"));
214        let second = load_or_mint(&path).expect("reload");
215        assert_eq!(first, second);
216        // No temporary file is left behind beside the id.
217        let entries: Vec<_> = fs::read_dir(path.parent().unwrap())
218            .unwrap()
219            .map(|entry| entry.unwrap().file_name())
220            .collect();
221        assert_eq!(
222            entries,
223            vec![std::ffi::OsString::from(MACHINE_ID_FILE_NAME)]
224        );
225    }
226
227    #[test]
228    fn a_file_without_a_trailing_newline_or_with_crlf_is_accepted() {
229        let dir = temp_dir("endings");
230        let path = dir.join(MACHINE_ID_FILE_NAME);
231        for content in [
232            "0123456789abcdef0123456789abcdef",
233            "0123456789abcdef0123456789abcdef\n",
234            "0123456789abcdef0123456789abcdef\r\n",
235        ] {
236            fs::write(&path, content).unwrap();
237            assert_eq!(
238                load_or_mint(&path).unwrap().as_str(),
239                "0123456789abcdef0123456789abcdef"
240            );
241            assert_eq!(fs::read_to_string(&path).unwrap(), content);
242        }
243    }
244
245    #[test]
246    fn a_corrupt_file_is_refused_by_name_and_left_untouched() {
247        let dir = temp_dir("corrupt");
248        let path = dir.join(MACHINE_ID_FILE_NAME);
249        for content in [
250            "",
251            "0123456789ABCDEF0123456789abcdef\n",
252            "0123456789abcdef0123456789abcdef\n\n",
253            " 0123456789abcdef0123456789abcdef",
254            "not an id",
255        ] {
256            fs::write(&path, content).unwrap();
257            let err = load_or_mint(&path).expect_err("corrupt file must refuse");
258            assert!(
259                matches!(&err, MachineIdFileError::Corrupt { path: p, .. } if p == &path),
260                "{content:?}: {err}"
261            );
262            assert!(err.to_string().contains(&path.display().to_string()));
263            assert_eq!(fs::read_to_string(&path).unwrap(), content);
264        }
265        fs::write(&path, [0xffu8, 0xfe]).unwrap();
266        assert!(matches!(
267            load_or_mint(&path),
268            Err(MachineIdFileError::Corrupt { .. })
269        ));
270        assert_eq!(fs::read(&path).unwrap(), vec![0xff, 0xfe]);
271    }
272
273    #[test]
274    fn write_replaces_the_value_atomically() {
275        let dir = temp_dir("write");
276        let path = dir.join(MACHINE_ID_FILE_NAME);
277        let first = load_or_mint(&path).unwrap();
278        let adopted = MachineId::parse("fedcba9876543210fedcba9876543210").unwrap();
279        write(&path, &adopted).unwrap();
280        assert_ne!(first, adopted);
281        assert_eq!(read(&path).unwrap(), Some(adopted));
282    }
283}