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