1use 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
24pub const MACHINE_ID_FILE_NAME: &str = "machine-id";
26
27pub 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
41pub 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 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
67pub 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
93pub 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
135fn 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#[derive(Debug)]
146pub enum MachineIdFileError {
147 RelativeDataHome { data_home: PathBuf },
150 Corrupt { path: PathBuf, reason: String },
153 Read { path: PathBuf, source: io::Error },
155 Write { path: PathBuf, source: io::Error },
157 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 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 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}