1use std::{
2 error::Error,
3 fmt,
4 fs::{self, File, OpenOptions},
5 io::{self, Write},
6 path::{Path, PathBuf},
7 process,
8 time::{Duration, SystemTime},
9};
10
11use serde::{Deserialize, Serialize};
12use subc_protocol::PROTOCOL_VERSION;
13
14pub const SCHEMA_VERSION: u32 = 1;
15pub const MIN_KEY_LEN: usize = 32;
16pub const KEY_LEN: usize = 32;
17pub const DAEMON_ID_LEN: usize = 16;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Endpoint {
21 pub host: String,
22 pub port: u16,
23}
24
25#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ConnectionInfo {
27 pub schema: u32,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub wire_version: Option<u8>,
30 pub endpoints: Vec<Endpoint>,
31 pub key: Vec<u8>,
32 pub daemon_id: [u8; DAEMON_ID_LEN],
33 pub pid: u32,
34 pub daemon_ver: String,
35}
36
37impl fmt::Debug for ConnectionInfo {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.debug_struct("ConnectionInfo")
42 .field("schema", &self.schema)
43 .field("wire_version", &self.wire_version)
44 .field("endpoints", &self.endpoints)
45 .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
46 .field("daemon_id", &self.daemon_id)
47 .field("pid", &self.pid)
48 .field("daemon_ver", &self.daemon_ver)
49 .finish()
50 }
51}
52
53impl ConnectionInfo {
54 pub fn validate(&self) -> Result<(), ConnectionFileError> {
55 if self.schema != SCHEMA_VERSION {
56 return Err(ConnectionFileError::UnsupportedSchema {
57 schema: self.schema,
58 supported: SCHEMA_VERSION,
59 });
60 }
61 if self.endpoints.is_empty() {
62 return Err(ConnectionFileError::Invalid {
63 reason: "connection file must include at least one endpoint".to_owned(),
64 });
65 }
66 if self.key.len() < MIN_KEY_LEN {
67 return Err(ConnectionFileError::KeyTooShort {
68 len: self.key.len(),
69 min: MIN_KEY_LEN,
70 });
71 }
72 Ok(())
73 }
74
75 pub fn validate_wire_version(&self, supported: u8) -> Result<(), ConnectionFileError> {
78 if let Some(file) = self.wire_version {
79 if file != supported {
80 return Err(ConnectionFileError::WireVersionMismatch { file, supported });
81 }
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug)]
88pub enum ConnectionFileError {
89 MissingParent {
90 path: PathBuf,
91 },
92 MissingFileName {
93 path: PathBuf,
94 },
95 Io {
96 op: &'static str,
97 path: PathBuf,
98 source: io::Error,
99 },
100 JsonRead {
101 path: PathBuf,
102 source: serde_json::Error,
103 },
104 JsonWrite {
105 path: PathBuf,
106 source: serde_json::Error,
107 },
108 Random(getrandom::Error),
109 UnsupportedSchema {
110 schema: u32,
111 supported: u32,
112 },
113 WireVersionMismatch {
114 file: u8,
115 supported: u8,
116 },
117 Invalid {
118 reason: String,
119 },
120 KeyTooShort {
121 len: usize,
122 min: usize,
123 },
124 InsecurePermissions {
125 path: PathBuf,
126 mode: u32,
127 },
128}
129
130pub fn write_atomic(
131 path: impl AsRef<Path>,
132 info: &ConnectionInfo,
133) -> Result<(), ConnectionFileError> {
134 let path = path.as_ref();
135 info.validate()?;
136
137 let parent = path
138 .parent()
139 .filter(|parent| !parent.as_os_str().is_empty())
140 .ok_or_else(|| ConnectionFileError::MissingParent {
141 path: path.to_path_buf(),
142 })?;
143 let file_name = path
144 .file_name()
145 .ok_or_else(|| ConnectionFileError::MissingFileName {
146 path: path.to_path_buf(),
147 })?;
148 sweep_stale_temps(parent, file_name);
160
161 let temp_path = temp_path(parent, file_name)?;
162 let result = write_atomic_inner(path, &temp_path, info);
163 if result.is_err() {
164 let _ = fs::remove_file(&temp_path);
165 }
166 result
167}
168
169fn sweep_stale_temps(parent: &Path, file_name: &std::ffi::OsStr) {
178 const STALE_AFTER: Duration = Duration::from_secs(600);
179
180 let prefix = format!(".{}.", file_name.to_string_lossy());
181 let Ok(entries) = fs::read_dir(parent) else {
182 return;
183 };
184 for entry in entries.flatten() {
185 let name = entry.file_name();
186 let name = name.to_string_lossy();
187 if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
188 continue;
189 }
190 let stale = entry
191 .metadata()
192 .and_then(|meta| meta.modified())
193 .map(|modified| {
194 SystemTime::now()
195 .duration_since(modified)
196 .is_ok_and(|age| age >= STALE_AFTER)
197 })
198 .unwrap_or(false);
199 if stale {
200 let _ = fs::remove_file(entry.path());
201 }
202 }
203}
204
205pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
206 let path = path.as_ref();
207 verify_owner_only(path)?;
211 let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
212 op: "read",
213 path: path.to_path_buf(),
214 source,
215 })?;
216 let info: ConnectionInfo =
217 serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
218 path: path.to_path_buf(),
219 source,
220 })?;
221 info.validate()?;
222 Ok(info)
223}
224
225pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
228 let info = read(path)?;
229 info.validate_wire_version(PROTOCOL_VERSION)?;
230 Ok(info)
231}
232
233#[cfg(unix)]
234fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
235 use std::os::unix::fs::PermissionsExt;
236 let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
237 op: "stat",
238 path: path.to_path_buf(),
239 source,
240 })?;
241 let mode = meta.permissions().mode();
242 if mode & 0o077 != 0 {
245 return Err(ConnectionFileError::InsecurePermissions {
246 path: path.to_path_buf(),
247 mode: mode & 0o777,
248 });
249 }
250 Ok(())
251}
252
253#[cfg(not(unix))]
254fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
255 Ok(())
259}
260
261pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
262 let mut key = vec![0u8; KEY_LEN];
263 getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
264 Ok(key)
265}
266
267pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
268 let mut daemon_id = [0u8; DAEMON_ID_LEN];
269 getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
270 Ok(daemon_id)
271}
272
273fn write_atomic_inner(
274 path: &Path,
275 temp_path: &Path,
276 info: &ConnectionInfo,
277) -> Result<(), ConnectionFileError> {
278 let json =
279 serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
280 path: path.to_path_buf(),
281 source,
282 })?;
283
284 {
285 let mut file =
286 open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
287 op: "create_temp",
288 path: temp_path.to_path_buf(),
289 source,
290 })?;
291 file.write_all(&json)
292 .and_then(|()| file.sync_all())
293 .map_err(|source| ConnectionFileError::Io {
294 op: "write_temp",
295 path: temp_path.to_path_buf(),
296 source,
297 })?;
298 }
299
300 fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
301 op: "rename",
302 path: path.to_path_buf(),
303 source,
304 })?;
305 Ok(())
306}
307
308fn open_owner_only_new(path: &Path) -> io::Result<File> {
309 let mut options = OpenOptions::new();
310 options.write(true).create_new(true);
311 #[cfg(unix)]
312 {
313 use std::os::unix::fs::OpenOptionsExt;
314 options.mode(0o600);
315 }
316 #[cfg(windows)]
317 {
318 }
330 options.open(path)
331}
332
333fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
334 let mut suffix = [0u8; 16];
335 getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
336 let file_name = file_name.to_string_lossy();
337 Ok(parent.join(format!(
338 ".{file_name}.{}.{}.tmp",
339 process::id(),
340 hex(&suffix)
341 )))
342}
343
344fn hex(bytes: &[u8]) -> String {
345 const HEX: &[u8; 16] = b"0123456789abcdef";
346 let mut out = String::with_capacity(bytes.len() * 2);
347 for byte in bytes {
348 out.push(HEX[(byte >> 4) as usize] as char);
349 out.push(HEX[(byte & 0x0f) as usize] as char);
350 }
351 out
352}
353
354impl fmt::Display for ConnectionFileError {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 match self {
357 Self::MissingParent { path } => {
358 write!(f, "connection file path has no parent: {}", path.display())
359 }
360 Self::MissingFileName { path } => {
361 write!(
362 f,
363 "connection file path has no file name: {}",
364 path.display()
365 )
366 }
367 Self::Io { op, path, source } => write!(
368 f,
369 "connection file {op} failed for {}: {source}",
370 path.display()
371 ),
372 Self::JsonRead { path, source } => write!(
373 f,
374 "connection file JSON read failed for {}: {source}",
375 path.display()
376 ),
377 Self::JsonWrite { path, source } => write!(
378 f,
379 "connection file JSON write failed for {}: {source}",
380 path.display()
381 ),
382 Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
383 Self::UnsupportedSchema { schema, supported } => write!(
384 f,
385 "unsupported connection file schema {schema}; expected {supported}"
386 ),
387 Self::WireVersionMismatch { file, supported } => write!(
388 f,
389 "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
390 ),
391 Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
392 Self::KeyTooShort { len, min } => write!(
393 f,
394 "connection file key is too short: {len} bytes, need at least {min}"
395 ),
396 Self::InsecurePermissions { path, mode } => write!(
397 f,
398 "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
399 path.display()
400 ),
401 }
402 }
403}
404
405impl Error for ConnectionFileError {
406 fn source(&self) -> Option<&(dyn Error + 'static)> {
407 match self {
408 Self::Io { source, .. } => Some(source),
409 Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
410 Self::Random(_) => None,
411 Self::MissingParent { .. }
412 | Self::MissingFileName { .. }
413 | Self::UnsupportedSchema { .. }
414 | Self::WireVersionMismatch { .. }
415 | Self::Invalid { .. }
416 | Self::KeyTooShort { .. }
417 | Self::InsecurePermissions { .. } => None,
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn sample_info() -> ConnectionInfo {
427 ConnectionInfo {
428 schema: SCHEMA_VERSION,
429 wire_version: None,
430 endpoints: vec![Endpoint {
431 host: "127.0.0.1".to_owned(),
432 port: 8799,
433 }],
434 key: vec![0xABu8; KEY_LEN],
435 daemon_id: [0x11u8; DAEMON_ID_LEN],
436 pid: 4242,
437 daemon_ver: "subc-test".to_owned(),
438 }
439 }
440
441 fn unique_temp_path() -> PathBuf {
442 let mut suffix = [0u8; 8];
443 getrandom::getrandom(&mut suffix).expect("random suffix");
444 let mut name = String::from("subc-connfile-test-");
445 for byte in suffix {
446 name.push_str(&format!("{byte:02x}"));
447 }
448 name.push_str(".json");
449 std::env::temp_dir().join(name)
450 }
451
452 #[test]
453 fn write_atomic_sweeps_stale_temps_and_spares_recent_and_unrelated_files() {
454 let dir = std::env::temp_dir().join(format!("subc-sweep-{}", process::id()));
455 fs::create_dir_all(&dir).expect("create dir");
456 let target = dir.join("subc-connection.json");
457
458 let stale = dir.join(".subc-connection.json.99999.deadbeef.tmp");
460 fs::write(&stale, b"stranded").expect("write stale");
461 let old = SystemTime::now() - Duration::from_secs(3600);
462 File::options()
463 .write(true)
464 .open(&stale)
465 .expect("open stale")
466 .set_modified(old)
467 .expect("backdate stale");
468
469 let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
472 fs::write(&recent, b"in flight").expect("write recent");
473
474 let unrelated = dir.join("unrelated.txt");
476 fs::write(&unrelated, b"not ours").expect("write unrelated");
477 File::options()
478 .write(true)
479 .open(&unrelated)
480 .expect("open unrelated")
481 .set_modified(old)
482 .expect("backdate unrelated");
483
484 write_atomic(&target, &sample_info()).expect("publish");
485
486 assert!(!stale.exists(), "a stale temp must be swept");
487 assert!(
488 recent.exists(),
489 "a recent temp may belong to an in-flight publish and must be spared"
490 );
491 assert!(
492 unrelated.exists(),
493 "age alone must not condemn a file that is not one of our temps"
494 );
495 assert!(target.exists(), "the publish itself must still land");
496
497 let _ = fs::remove_dir_all(&dir);
498 }
499
500 #[test]
501 fn debug_redacts_key_bytes() {
502 let info = sample_info();
503 let rendered = format!("{info:?}");
504 assert!(
505 rendered.contains("redacted"),
506 "Debug must mark the key as redacted: {rendered}"
507 );
508 assert!(
510 !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
511 "Debug must not leak raw key bytes: {rendered}"
512 );
513 }
514
515 #[test]
516 fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
517 let mut unsupported_schema = sample_info();
518 unsupported_schema.schema = SCHEMA_VERSION + 1;
519 let before = unsupported_schema.clone();
520 let err = unsupported_schema
521 .validate()
522 .expect_err("unsupported schema must be rejected");
523 assert!(matches!(
524 err,
525 ConnectionFileError::UnsupportedSchema {
526 schema,
527 supported: SCHEMA_VERSION,
528 } if schema == SCHEMA_VERSION + 1
529 ));
530 assert_eq!(unsupported_schema, before, "validate must not mutate input");
531
532 let mut empty_endpoints = sample_info();
533 empty_endpoints.endpoints.clear();
534 let before = empty_endpoints.clone();
535 let err = empty_endpoints
536 .validate()
537 .expect_err("empty endpoint list must be rejected");
538 assert!(matches!(
539 err,
540 ConnectionFileError::Invalid { ref reason }
541 if reason == "connection file must include at least one endpoint"
542 ));
543 assert_eq!(empty_endpoints, before, "validate must not mutate input");
544
545 let mut short_key = sample_info();
546 short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
547 let before = short_key.clone();
548 let err = short_key
549 .validate()
550 .expect_err("short key must be rejected");
551 assert!(matches!(
552 err,
553 ConnectionFileError::KeyTooShort {
554 len,
555 min: MIN_KEY_LEN,
556 } if len == MIN_KEY_LEN - 1
557 ));
558 assert_eq!(short_key, before, "validate must not mutate input");
559 }
560
561 #[test]
562 fn optional_wire_version_round_trips() {
563 let path = unique_temp_path();
564 let legacy = sample_info();
565 write_atomic(&path, &legacy).expect("write legacy connection file");
566 let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
567 assert!(!legacy_json.contains("wire_version"));
568 assert_eq!(
569 read_for_client(&path).expect("legacy file remains readable"),
570 legacy
571 );
572
573 let mut current = sample_info();
574 current.wire_version = Some(PROTOCOL_VERSION);
575 write_atomic(&path, ¤t).expect("write current connection file");
576 let current_json = fs::read_to_string(&path).expect("read current connection file");
577 let current_json: serde_json::Value =
578 serde_json::from_str(¤t_json).expect("parse current connection file");
579 assert_eq!(
580 current_json["wire_version"].as_u64(),
581 Some(u64::from(PROTOCOL_VERSION))
582 );
583 assert_eq!(
584 read_for_client(&path).expect("current file is readable"),
585 current
586 );
587 let _ = fs::remove_file(&path);
588 }
589
590 #[test]
591 fn read_for_client_rejects_mismatched_wire_version() {
592 let path = unique_temp_path();
593 let mut info = sample_info();
594 let file_version = PROTOCOL_VERSION + 1;
595 info.wire_version = Some(file_version);
596 write_atomic(&path, &info).expect("write mismatched connection file");
597
598 let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
599 assert!(matches!(
600 err,
601 ConnectionFileError::WireVersionMismatch { file, supported }
602 if file == file_version && supported == PROTOCOL_VERSION
603 ));
604 let rendered = err.to_string();
605 assert!(rendered.contains(&file_version.to_string()));
606 assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
607 assert!(rendered.contains("binary must be upgraded"));
608 let _ = fs::remove_file(&path);
609 }
610
611 #[cfg(unix)]
612 #[test]
613 fn read_rejects_group_or_world_readable_file() {
614 use std::os::unix::fs::PermissionsExt;
615
616 let path = unique_temp_path();
617 write_atomic(&path, &sample_info()).expect("write owner-only file");
618 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
620
621 let err = read(&path).expect_err("group/world-readable key file must be rejected");
622 assert!(
623 matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
624 "expected InsecurePermissions, got {err:?}"
625 );
626 let _ = fs::remove_file(&path);
627 }
628}