1use std::{
2 error::Error,
3 fmt,
4 fs::{self, File, OpenOptions},
5 io::{self, Write},
6 path::{Path, PathBuf},
7 process,
8};
9
10use serde::{Deserialize, Serialize};
11
12pub const SCHEMA_VERSION: u32 = 1;
13pub const MIN_KEY_LEN: usize = 32;
14pub const KEY_LEN: usize = 32;
15pub const DAEMON_ID_LEN: usize = 16;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Endpoint {
19 pub host: String,
20 pub port: u16,
21}
22
23#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ConnectionInfo {
25 pub schema: u32,
26 pub endpoints: Vec<Endpoint>,
27 pub key: Vec<u8>,
28 pub daemon_id: [u8; DAEMON_ID_LEN],
29 pub pid: u32,
30 pub daemon_ver: String,
31}
32
33impl fmt::Debug for ConnectionInfo {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.debug_struct("ConnectionInfo")
38 .field("schema", &self.schema)
39 .field("endpoints", &self.endpoints)
40 .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
41 .field("daemon_id", &self.daemon_id)
42 .field("pid", &self.pid)
43 .field("daemon_ver", &self.daemon_ver)
44 .finish()
45 }
46}
47
48impl ConnectionInfo {
49 pub fn validate(&self) -> Result<(), ConnectionFileError> {
50 if self.schema != SCHEMA_VERSION {
51 return Err(ConnectionFileError::UnsupportedSchema {
52 schema: self.schema,
53 supported: SCHEMA_VERSION,
54 });
55 }
56 if self.endpoints.is_empty() {
57 return Err(ConnectionFileError::Invalid {
58 reason: "connection file must include at least one endpoint".to_owned(),
59 });
60 }
61 if self.key.len() < MIN_KEY_LEN {
62 return Err(ConnectionFileError::KeyTooShort {
63 len: self.key.len(),
64 min: MIN_KEY_LEN,
65 });
66 }
67 Ok(())
68 }
69}
70
71#[derive(Debug)]
72pub enum ConnectionFileError {
73 MissingParent {
74 path: PathBuf,
75 },
76 MissingFileName {
77 path: PathBuf,
78 },
79 Io {
80 op: &'static str,
81 path: PathBuf,
82 source: io::Error,
83 },
84 JsonRead {
85 path: PathBuf,
86 source: serde_json::Error,
87 },
88 JsonWrite {
89 path: PathBuf,
90 source: serde_json::Error,
91 },
92 Random(getrandom::Error),
93 UnsupportedSchema {
94 schema: u32,
95 supported: u32,
96 },
97 Invalid {
98 reason: String,
99 },
100 KeyTooShort {
101 len: usize,
102 min: usize,
103 },
104 InsecurePermissions {
105 path: PathBuf,
106 mode: u32,
107 },
108}
109
110pub fn write_atomic(
111 path: impl AsRef<Path>,
112 info: &ConnectionInfo,
113) -> Result<(), ConnectionFileError> {
114 let path = path.as_ref();
115 info.validate()?;
116
117 let parent = path
118 .parent()
119 .filter(|parent| !parent.as_os_str().is_empty())
120 .ok_or_else(|| ConnectionFileError::MissingParent {
121 path: path.to_path_buf(),
122 })?;
123 let file_name = path
124 .file_name()
125 .ok_or_else(|| ConnectionFileError::MissingFileName {
126 path: path.to_path_buf(),
127 })?;
128 let temp_path = temp_path(parent, file_name)?;
129 let result = write_atomic_inner(path, &temp_path, info);
130 if result.is_err() {
131 let _ = fs::remove_file(&temp_path);
132 }
133 result
134}
135
136pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
137 let path = path.as_ref();
138 verify_owner_only(path)?;
142 let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
143 op: "read",
144 path: path.to_path_buf(),
145 source,
146 })?;
147 let info: ConnectionInfo =
148 serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
149 path: path.to_path_buf(),
150 source,
151 })?;
152 info.validate()?;
153 Ok(info)
154}
155
156#[cfg(unix)]
157fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
158 use std::os::unix::fs::PermissionsExt;
159 let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
160 op: "stat",
161 path: path.to_path_buf(),
162 source,
163 })?;
164 let mode = meta.permissions().mode();
165 if mode & 0o077 != 0 {
168 return Err(ConnectionFileError::InsecurePermissions {
169 path: path.to_path_buf(),
170 mode: mode & 0o777,
171 });
172 }
173 Ok(())
174}
175
176#[cfg(not(unix))]
177fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
178 Ok(())
182}
183
184pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
185 let mut key = vec![0u8; KEY_LEN];
186 getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
187 Ok(key)
188}
189
190pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
191 let mut daemon_id = [0u8; DAEMON_ID_LEN];
192 getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
193 Ok(daemon_id)
194}
195
196fn write_atomic_inner(
197 path: &Path,
198 temp_path: &Path,
199 info: &ConnectionInfo,
200) -> Result<(), ConnectionFileError> {
201 let json =
202 serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
203 path: path.to_path_buf(),
204 source,
205 })?;
206
207 {
208 let mut file =
209 open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
210 op: "create_temp",
211 path: temp_path.to_path_buf(),
212 source,
213 })?;
214 file.write_all(&json)
215 .and_then(|()| file.sync_all())
216 .map_err(|source| ConnectionFileError::Io {
217 op: "write_temp",
218 path: temp_path.to_path_buf(),
219 source,
220 })?;
221 }
222
223 fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
224 op: "rename",
225 path: path.to_path_buf(),
226 source,
227 })?;
228 Ok(())
229}
230
231fn open_owner_only_new(path: &Path) -> io::Result<File> {
232 let mut options = OpenOptions::new();
233 options.write(true).create_new(true);
234 #[cfg(unix)]
235 {
236 use std::os::unix::fs::OpenOptionsExt;
237 options.mode(0o600);
238 }
239 #[cfg(windows)]
240 {
241 }
253 options.open(path)
254}
255
256fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
257 let mut suffix = [0u8; 16];
258 getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
259 let file_name = file_name.to_string_lossy();
260 Ok(parent.join(format!(
261 ".{file_name}.{}.{}.tmp",
262 process::id(),
263 hex(&suffix)
264 )))
265}
266
267fn hex(bytes: &[u8]) -> String {
268 const HEX: &[u8; 16] = b"0123456789abcdef";
269 let mut out = String::with_capacity(bytes.len() * 2);
270 for byte in bytes {
271 out.push(HEX[(byte >> 4) as usize] as char);
272 out.push(HEX[(byte & 0x0f) as usize] as char);
273 }
274 out
275}
276
277impl fmt::Display for ConnectionFileError {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 match self {
280 Self::MissingParent { path } => {
281 write!(f, "connection file path has no parent: {}", path.display())
282 }
283 Self::MissingFileName { path } => {
284 write!(
285 f,
286 "connection file path has no file name: {}",
287 path.display()
288 )
289 }
290 Self::Io { op, path, source } => write!(
291 f,
292 "connection file {op} failed for {}: {source}",
293 path.display()
294 ),
295 Self::JsonRead { path, source } => write!(
296 f,
297 "connection file JSON read failed for {}: {source}",
298 path.display()
299 ),
300 Self::JsonWrite { path, source } => write!(
301 f,
302 "connection file JSON write failed for {}: {source}",
303 path.display()
304 ),
305 Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
306 Self::UnsupportedSchema { schema, supported } => write!(
307 f,
308 "unsupported connection file schema {schema}; expected {supported}"
309 ),
310 Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
311 Self::KeyTooShort { len, min } => write!(
312 f,
313 "connection file key is too short: {len} bytes, need at least {min}"
314 ),
315 Self::InsecurePermissions { path, mode } => write!(
316 f,
317 "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
318 path.display()
319 ),
320 }
321 }
322}
323
324impl Error for ConnectionFileError {
325 fn source(&self) -> Option<&(dyn Error + 'static)> {
326 match self {
327 Self::Io { source, .. } => Some(source),
328 Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
329 Self::Random(_) => None,
330 Self::MissingParent { .. }
331 | Self::MissingFileName { .. }
332 | Self::UnsupportedSchema { .. }
333 | Self::Invalid { .. }
334 | Self::KeyTooShort { .. }
335 | Self::InsecurePermissions { .. } => None,
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn sample_info() -> ConnectionInfo {
345 ConnectionInfo {
346 schema: SCHEMA_VERSION,
347 endpoints: vec![Endpoint {
348 host: "127.0.0.1".to_owned(),
349 port: 8799,
350 }],
351 key: vec![0xABu8; KEY_LEN],
352 daemon_id: [0x11u8; DAEMON_ID_LEN],
353 pid: 4242,
354 daemon_ver: "subc-test".to_owned(),
355 }
356 }
357
358 fn unique_temp_path() -> PathBuf {
359 let mut suffix = [0u8; 8];
360 getrandom::getrandom(&mut suffix).expect("random suffix");
361 let mut name = String::from("subc-connfile-test-");
362 for byte in suffix {
363 name.push_str(&format!("{byte:02x}"));
364 }
365 name.push_str(".json");
366 std::env::temp_dir().join(name)
367 }
368
369 #[test]
370 fn debug_redacts_key_bytes() {
371 let info = sample_info();
372 let rendered = format!("{info:?}");
373 assert!(
374 rendered.contains("redacted"),
375 "Debug must mark the key as redacted: {rendered}"
376 );
377 assert!(
379 !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
380 "Debug must not leak raw key bytes: {rendered}"
381 );
382 }
383
384 #[test]
385 fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
386 let mut unsupported_schema = sample_info();
387 unsupported_schema.schema = SCHEMA_VERSION + 1;
388 let before = unsupported_schema.clone();
389 let err = unsupported_schema
390 .validate()
391 .expect_err("unsupported schema must be rejected");
392 assert!(matches!(
393 err,
394 ConnectionFileError::UnsupportedSchema {
395 schema,
396 supported: SCHEMA_VERSION,
397 } if schema == SCHEMA_VERSION + 1
398 ));
399 assert_eq!(unsupported_schema, before, "validate must not mutate input");
400
401 let mut empty_endpoints = sample_info();
402 empty_endpoints.endpoints.clear();
403 let before = empty_endpoints.clone();
404 let err = empty_endpoints
405 .validate()
406 .expect_err("empty endpoint list must be rejected");
407 assert!(matches!(
408 err,
409 ConnectionFileError::Invalid { ref reason }
410 if reason == "connection file must include at least one endpoint"
411 ));
412 assert_eq!(empty_endpoints, before, "validate must not mutate input");
413
414 let mut short_key = sample_info();
415 short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
416 let before = short_key.clone();
417 let err = short_key
418 .validate()
419 .expect_err("short key must be rejected");
420 assert!(matches!(
421 err,
422 ConnectionFileError::KeyTooShort {
423 len,
424 min: MIN_KEY_LEN,
425 } if len == MIN_KEY_LEN - 1
426 ));
427 assert_eq!(short_key, before, "validate must not mutate input");
428 }
429
430 #[test]
431 fn read_accepts_owner_only_file() {
432 let path = unique_temp_path();
433 write_atomic(&path, &sample_info()).expect("write owner-only file");
434 let read_back = read(&path).expect("owner-only file is readable");
435 assert_eq!(read_back, sample_info());
436 let _ = fs::remove_file(&path);
437 }
438
439 #[cfg(unix)]
440 #[test]
441 fn read_rejects_group_or_world_readable_file() {
442 use std::os::unix::fs::PermissionsExt;
443
444 let path = unique_temp_path();
445 write_atomic(&path, &sample_info()).expect("write owner-only file");
446 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
448
449 let err = read(&path).expect_err("group/world-readable key file must be rejected");
450 assert!(
451 matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
452 "expected InsecurePermissions, got {err:?}"
453 );
454 let _ = fs::remove_file(&path);
455 }
456}