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) -> PathBuf {
205 let mut suffix = [0u8; 8];
206 getrandom::getrandom(&mut suffix).unwrap();
207 let dir = std::env::temp_dir().join(format!(
208 "subc-machine-id-{name}-{}-{:016x}",
209 std::process::id(),
210 u64::from_be_bytes(suffix)
211 ));
212 fs::create_dir_all(&dir).unwrap();
213 dir
214 }
215
216 #[test]
217 fn mint_writes_one_line_and_a_second_load_reads_the_same_id() {
218 let dir = temp_dir("mint");
219 let path = dir.join("cortexkit").join(MACHINE_ID_FILE_NAME);
220 let first = load_or_mint(&path).expect("mint");
221 assert_eq!(fs::read_to_string(&path).unwrap(), format!("{first}\n"));
222 let second = load_or_mint(&path).expect("reload");
223 assert_eq!(first, second);
224 let entries: Vec<_> = fs::read_dir(path.parent().unwrap())
226 .unwrap()
227 .map(|entry| entry.unwrap().file_name())
228 .collect();
229 assert_eq!(
230 entries,
231 vec![std::ffi::OsString::from(MACHINE_ID_FILE_NAME)]
232 );
233 let _ = fs::remove_dir_all(dir);
234 }
235
236 #[test]
237 fn a_file_without_a_trailing_newline_or_with_crlf_is_accepted() {
238 let dir = temp_dir("endings");
239 let path = dir.join(MACHINE_ID_FILE_NAME);
240 for content in [
241 "0123456789abcdef0123456789abcdef",
242 "0123456789abcdef0123456789abcdef\n",
243 "0123456789abcdef0123456789abcdef\r\n",
244 ] {
245 fs::write(&path, content).unwrap();
246 assert_eq!(
247 load_or_mint(&path).unwrap().as_str(),
248 "0123456789abcdef0123456789abcdef"
249 );
250 assert_eq!(fs::read_to_string(&path).unwrap(), content);
251 }
252 let _ = fs::remove_dir_all(dir);
253 }
254
255 #[test]
256 fn a_corrupt_file_is_refused_by_name_and_left_untouched() {
257 let dir = temp_dir("corrupt");
258 let path = dir.join(MACHINE_ID_FILE_NAME);
259 for content in [
260 "",
261 "0123456789ABCDEF0123456789abcdef\n",
262 "0123456789abcdef0123456789abcdef\n\n",
263 " 0123456789abcdef0123456789abcdef",
264 "not an id",
265 ] {
266 fs::write(&path, content).unwrap();
267 let err = load_or_mint(&path).expect_err("corrupt file must refuse");
268 assert!(
269 matches!(&err, MachineIdFileError::Corrupt { path: p, .. } if p == &path),
270 "{content:?}: {err}"
271 );
272 assert!(err.to_string().contains(&path.display().to_string()));
273 assert_eq!(fs::read_to_string(&path).unwrap(), content);
274 }
275 fs::write(&path, [0xffu8, 0xfe]).unwrap();
276 assert!(matches!(
277 load_or_mint(&path),
278 Err(MachineIdFileError::Corrupt { .. })
279 ));
280 assert_eq!(fs::read(&path).unwrap(), vec![0xff, 0xfe]);
281 let _ = fs::remove_dir_all(dir);
282 }
283
284 #[test]
285 fn write_replaces_the_value_atomically() {
286 let dir = temp_dir("write");
287 let path = dir.join(MACHINE_ID_FILE_NAME);
288 let first = load_or_mint(&path).unwrap();
289 let adopted = MachineId::parse("fedcba9876543210fedcba9876543210").unwrap();
290 write(&path, &adopted).unwrap();
291 assert_ne!(first, adopted);
292 assert_eq!(read(&path).unwrap(), Some(adopted));
293 let _ = fs::remove_dir_all(dir);
294 }
295}