1use std::{
2 env,
3 error::Error,
4 ffi::{OsStr, OsString},
5 fmt,
6 fs::{self, File, OpenOptions},
7 io::{self, Write},
8 path::{Path, PathBuf},
9 process,
10 time::{Duration, SystemTime},
11};
12
13use serde::{Deserialize, Serialize};
14use subc_protocol::PROTOCOL_VERSION;
15
16pub const SCHEMA_VERSION: u32 = 1;
17pub const MIN_KEY_LEN: usize = 32;
18pub const KEY_LEN: usize = 32;
19pub const DAEMON_ID_LEN: usize = 16;
20
21pub const CONNECTION_FILE_NAME: &str = "subc-connection.json";
25const PROD_CONNECTION_RELATIVE_PATH: &[&str] =
26 &[".local", "share", "cortexkit", "run", CONNECTION_FILE_NAME];
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Endpoint {
30 pub host: String,
31 pub port: u16,
32}
33
34#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ConnectionInfo {
36 pub schema: u32,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub wire_version: Option<u8>,
39 pub endpoints: Vec<Endpoint>,
40 pub key: Vec<u8>,
41 pub daemon_id: [u8; DAEMON_ID_LEN],
42 pub pid: u32,
43 pub daemon_ver: String,
44}
45
46impl fmt::Debug for ConnectionInfo {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.debug_struct("ConnectionInfo")
51 .field("schema", &self.schema)
52 .field("wire_version", &self.wire_version)
53 .field("endpoints", &self.endpoints)
54 .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
55 .field("daemon_id", &self.daemon_id)
56 .field("pid", &self.pid)
57 .field("daemon_ver", &self.daemon_ver)
58 .finish()
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Discovered {
65 pub path: PathBuf,
66 pub info: ConnectionInfo,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TriedCandidate {
72 pub path: PathBuf,
73 pub reason: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DiscoveryError {
79 pub tried: Vec<TriedCandidate>,
80}
81
82impl fmt::Display for DiscoveryError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 let rendered = self
85 .tried
86 .iter()
87 .map(|attempt| format!("{} ({})", attempt.path.display(), attempt.reason))
88 .collect::<Vec<_>>()
89 .join(", ");
90 write!(f, "no usable subc connection file found; tried: {rendered}")
91 }
92}
93
94impl Error for DiscoveryError {}
95
96impl ConnectionInfo {
97 pub fn validate(&self) -> Result<(), ConnectionFileError> {
98 if self.schema != SCHEMA_VERSION {
99 return Err(ConnectionFileError::UnsupportedSchema {
100 schema: self.schema,
101 supported: SCHEMA_VERSION,
102 });
103 }
104 if self.endpoints.is_empty() {
105 return Err(ConnectionFileError::Invalid {
106 reason: "connection file must include at least one endpoint".to_owned(),
107 });
108 }
109 if self.key.len() < MIN_KEY_LEN {
110 return Err(ConnectionFileError::KeyTooShort {
111 len: self.key.len(),
112 min: MIN_KEY_LEN,
113 });
114 }
115 Ok(())
116 }
117
118 pub fn validate_wire_version(&self, supported: u8) -> Result<(), ConnectionFileError> {
121 if let Some(file) = self.wire_version {
122 if file != supported {
123 return Err(ConnectionFileError::WireVersionMismatch { file, supported });
124 }
125 }
126 Ok(())
127 }
128}
129
130#[derive(Debug)]
131pub enum ConnectionFileError {
132 MissingParent {
133 path: PathBuf,
134 },
135 MissingFileName {
136 path: PathBuf,
137 },
138 Io {
139 op: &'static str,
140 path: PathBuf,
141 source: io::Error,
142 },
143 JsonRead {
144 path: PathBuf,
145 source: serde_json::Error,
146 },
147 JsonWrite {
148 path: PathBuf,
149 source: serde_json::Error,
150 },
151 Random(getrandom::Error),
152 UnsupportedSchema {
153 schema: u32,
154 supported: u32,
155 },
156 WireVersionMismatch {
157 file: u8,
158 supported: u8,
159 },
160 Invalid {
161 reason: String,
162 },
163 KeyTooShort {
164 len: usize,
165 min: usize,
166 },
167 InsecurePermissions {
168 path: PathBuf,
169 mode: u32,
170 },
171 InsecureParentDirectory {
172 component: PathBuf,
173 mode: u32,
174 },
175}
176
177pub fn write_atomic(
178 path: impl AsRef<Path>,
179 info: &ConnectionInfo,
180) -> Result<(), ConnectionFileError> {
181 let path = path.as_ref();
182 info.validate()?;
183
184 let parent = path
185 .parent()
186 .filter(|parent| !parent.as_os_str().is_empty())
187 .ok_or_else(|| ConnectionFileError::MissingParent {
188 path: path.to_path_buf(),
189 })?;
190 let file_name = path
191 .file_name()
192 .ok_or_else(|| ConnectionFileError::MissingFileName {
193 path: path.to_path_buf(),
194 })?;
195 ensure_parent_directory(parent)?;
196 refuse_writable_ancestor(parent)?;
197 sweep_stale_temps(parent, file_name);
209
210 let temp_path = temp_path(parent, file_name)?;
211 let result = write_atomic_inner(path, &temp_path, info);
212 if result.is_err() {
213 let _ = fs::remove_file(&temp_path);
214 }
215 result
216}
217
218fn ensure_parent_directory(parent: &Path) -> Result<(), ConnectionFileError> {
229 if parent.exists() {
230 return Ok(());
231 }
232 #[cfg(unix)]
233 let created = {
234 use std::os::unix::fs::DirBuilderExt;
235 fs::DirBuilder::new()
236 .recursive(true)
237 .mode(0o700)
238 .create(parent)
239 };
240 #[cfg(not(unix))]
241 let created = fs::create_dir_all(parent);
242
243 created.map_err(|source| ConnectionFileError::Io {
244 op: "create connection-file parent",
245 path: parent.to_path_buf(),
246 source,
247 })
248}
249
250#[cfg(unix)]
276fn refuse_writable_ancestor(parent: &Path) -> Result<(), ConnectionFileError> {
277 use std::os::unix::fs::PermissionsExt;
278
279 const GROUP_OR_WORLD_WRITABLE: u32 = 0o022;
280 const STICKY: u32 = 0o1000;
281
282 let Ok(resolved) = fs::canonicalize(parent) else {
286 return Ok(());
287 };
288
289 let mut component = resolved.as_path();
290 loop {
291 if let Ok(metadata) = fs::metadata(component) {
300 let mode = metadata.permissions().mode();
301 if mode & GROUP_OR_WORLD_WRITABLE != 0 && mode & STICKY == 0 {
302 return Err(ConnectionFileError::InsecureParentDirectory {
303 component: component.to_path_buf(),
304 mode: mode & 0o7777,
305 });
306 }
307 }
308 match component.parent() {
309 Some(next) => component = next,
310 None => return Ok(()),
311 }
312 }
313}
314
315#[cfg(not(unix))]
316fn refuse_writable_ancestor(_parent: &Path) -> Result<(), ConnectionFileError> {
317 Ok(())
320}
321
322fn sweep_stale_temps(parent: &Path, file_name: &std::ffi::OsStr) {
331 const STALE_AFTER: Duration = Duration::from_secs(600);
332
333 let prefix = format!(".{}.", file_name.to_string_lossy());
334 let Ok(entries) = fs::read_dir(parent) else {
335 return;
336 };
337 for entry in entries.flatten() {
338 let name = entry.file_name();
339 let name = name.to_string_lossy();
340 if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
341 continue;
342 }
343 let stale = entry
344 .metadata()
345 .and_then(|meta| meta.modified())
346 .map(|modified| {
347 SystemTime::now()
348 .duration_since(modified)
349 .is_ok_and(|age| age >= STALE_AFTER)
350 })
351 .unwrap_or(false);
352 if stale {
353 let _ = fs::remove_file(entry.path());
354 }
355 }
356}
357
358pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
359 let path = path.as_ref();
360 verify_owner_only(path)?;
364 let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
365 op: "read",
366 path: path.to_path_buf(),
367 source,
368 })?;
369 let info: ConnectionInfo =
370 serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
371 path: path.to_path_buf(),
372 source,
373 })?;
374 info.validate()?;
375 Ok(info)
376}
377
378pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
381 let info = read(path)?;
382 info.validate_wire_version(PROTOCOL_VERSION)?;
383 Ok(info)
384}
385
386pub fn discovery_candidates(explicit: Option<&Path>, env_named: Option<&OsStr>) -> Vec<PathBuf> {
391 let runtime_dir = non_empty_os_var("XDG_RUNTIME_DIR");
392 let home = non_empty_os_var("HOME");
393 discovery_candidates_with_environment(
394 explicit,
395 env_named,
396 runtime_dir.as_deref(),
397 home.as_deref(),
398 &env::temp_dir(),
399 )
400}
401
402pub fn discover(explicit: Option<&Path>) -> Result<Discovered, DiscoveryError> {
406 let env_named = non_empty_os_var("SUBC_CONNECTION_FILE");
407 discover_candidates(discovery_candidates(explicit, env_named.as_deref()))
408}
409
410fn discovery_candidates_with_environment(
411 explicit: Option<&Path>,
412 env_named: Option<&OsStr>,
413 runtime_dir: Option<&OsStr>,
414 home: Option<&OsStr>,
415 temp_dir: &Path,
416) -> Vec<PathBuf> {
417 if let Some(path) = explicit {
418 return vec![path.to_path_buf()];
419 }
420
421 let env_named = env_named.filter(|value| !value.is_empty());
422 let runtime_dir = runtime_dir.filter(|value| !value.is_empty());
423
424 if let Some(only) = env_named {
437 return vec![PathBuf::from(only)];
438 }
439
440 let mut candidates = Vec::new();
441 if let Some(runtime_dir) = runtime_dir {
442 push_unique(
443 &mut candidates,
444 PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
445 );
446 }
447 if let Some(home) = home {
448 let mut path = PathBuf::from(home);
449 for part in PROD_CONNECTION_RELATIVE_PATH {
450 path.push(part);
451 }
452 push_unique(&mut candidates, path);
453 }
454 push_unique(
455 &mut candidates,
456 temp_dir.join(format!("subc-{}.connection.json", user_connection_token())),
457 );
458 candidates
459}
460
461fn discover_candidates(candidates: Vec<PathBuf>) -> Result<Discovered, DiscoveryError> {
462 let mut tried = Vec::new();
463 for path in candidates {
464 match read_for_client(&path) {
465 Ok(info) => return Ok(Discovered { path, info }),
466 Err(source) => tried.push(TriedCandidate {
467 path,
468 reason: discovery_reason(&source),
469 }),
470 }
471 }
472 Err(DiscoveryError { tried })
473}
474
475fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
476 if !paths.iter().any(|existing| existing == &path) {
477 paths.push(path);
478 }
479}
480
481fn non_empty_os_var(key: &str) -> Option<OsString> {
482 let value = env::var_os(key)?;
483 if value.is_empty() {
484 None
485 } else {
486 Some(value)
487 }
488}
489
490fn discovery_reason(source: &ConnectionFileError) -> String {
491 match source {
492 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => {
493 "not found".to_string()
494 }
495 other => other.to_string(),
496 }
497}
498
499pub fn user_connection_token() -> String {
504 #[cfg(unix)]
508 {
509 rustix::process::getuid().as_raw().to_string()
510 }
511
512 #[cfg(not(unix))]
513 {
514 for key in ["USER", "USERNAME", "HOME", "USERPROFILE"] {
515 if let Some(value) = non_empty_os_var(key) {
516 return sanitize_token(&value.to_string_lossy());
517 }
518 }
519
520 "unknown".to_string()
521 }
522}
523
524#[cfg(not(unix))]
525fn sanitize_token(raw: &str) -> String {
526 let mut token = String::new();
527 for ch in raw.chars() {
528 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
529 token.push(ch);
530 } else {
531 token.push('_');
532 }
533 }
534 if token.is_empty() {
535 "unknown".to_string()
536 } else {
537 token
538 }
539}
540
541#[cfg(unix)]
542fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
543 use std::os::unix::fs::PermissionsExt;
544 let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
545 op: "stat",
546 path: path.to_path_buf(),
547 source,
548 })?;
549 let mode = meta.permissions().mode();
550 if mode & 0o077 != 0 {
553 return Err(ConnectionFileError::InsecurePermissions {
554 path: path.to_path_buf(),
555 mode: mode & 0o777,
556 });
557 }
558 Ok(())
559}
560
561#[cfg(not(unix))]
562fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
563 Ok(())
567}
568
569pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
570 let mut key = vec![0u8; KEY_LEN];
571 getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
572 Ok(key)
573}
574
575pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
576 let mut daemon_id = [0u8; DAEMON_ID_LEN];
577 getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
578 Ok(daemon_id)
579}
580
581fn write_atomic_inner(
582 path: &Path,
583 temp_path: &Path,
584 info: &ConnectionInfo,
585) -> Result<(), ConnectionFileError> {
586 let json =
587 serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
588 path: path.to_path_buf(),
589 source,
590 })?;
591
592 {
593 let mut file =
594 open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
595 op: "create_temp",
596 path: temp_path.to_path_buf(),
597 source,
598 })?;
599 file.write_all(&json)
600 .and_then(|()| file.sync_all())
601 .map_err(|source| ConnectionFileError::Io {
602 op: "write_temp",
603 path: temp_path.to_path_buf(),
604 source,
605 })?;
606 }
607
608 fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
609 op: "rename",
610 path: path.to_path_buf(),
611 source,
612 })?;
613 Ok(())
614}
615
616fn open_owner_only_new(path: &Path) -> io::Result<File> {
617 let mut options = OpenOptions::new();
618 options.write(true).create_new(true);
619 #[cfg(unix)]
620 {
621 use std::os::unix::fs::OpenOptionsExt;
622 options.mode(0o600);
623 }
624 #[cfg(windows)]
625 {
626 }
638 options.open(path)
639}
640
641fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
642 let mut suffix = [0u8; 16];
643 getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
644 let file_name = file_name.to_string_lossy();
645 Ok(parent.join(format!(
646 ".{file_name}.{}.{}.tmp",
647 process::id(),
648 hex(&suffix)
649 )))
650}
651
652fn hex(bytes: &[u8]) -> String {
653 const HEX: &[u8; 16] = b"0123456789abcdef";
654 let mut out = String::with_capacity(bytes.len() * 2);
655 for byte in bytes {
656 out.push(HEX[(byte >> 4) as usize] as char);
657 out.push(HEX[(byte & 0x0f) as usize] as char);
658 }
659 out
660}
661
662impl fmt::Display for ConnectionFileError {
663 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
664 match self {
665 Self::MissingParent { path } => {
666 write!(f, "connection file path has no parent: {}", path.display())
667 }
668 Self::MissingFileName { path } => {
669 write!(
670 f,
671 "connection file path has no file name: {}",
672 path.display()
673 )
674 }
675 Self::Io { op, path, source } => write!(
676 f,
677 "connection file {op} failed for {}: {source}",
678 path.display()
679 ),
680 Self::JsonRead { path, source } => write!(
681 f,
682 "connection file JSON read failed for {}: {source}",
683 path.display()
684 ),
685 Self::JsonWrite { path, source } => write!(
686 f,
687 "connection file JSON write failed for {}: {source}",
688 path.display()
689 ),
690 Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
691 Self::UnsupportedSchema { schema, supported } => write!(
692 f,
693 "unsupported connection file schema {schema}; expected {supported}"
694 ),
695 Self::WireVersionMismatch { file, supported } => write!(
696 f,
697 "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
698 ),
699 Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
700 Self::KeyTooShort { len, min } => write!(
701 f,
702 "connection file key is too short: {len} bytes, need at least {min}"
703 ),
704 Self::InsecurePermissions { path, mode } => write!(
705 f,
706 "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
707 path.display()
708 ),
709 Self::InsecureParentDirectory { component, mode } => write!(
710 f,
711 "refusing to publish the connection file: ancestor {} is mode {mode:#o}, \
712 which lets another user replace the file regardless of its own 0600 mode; \
713 this is a misconfiguration check, not a defence against a same-uid caller",
714 component.display()
715 ),
716 }
717 }
718}
719
720impl Error for ConnectionFileError {
721 fn source(&self) -> Option<&(dyn Error + 'static)> {
722 match self {
723 Self::Io { source, .. } => Some(source),
724 Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
725 Self::Random(_) => None,
726 Self::MissingParent { .. }
727 | Self::MissingFileName { .. }
728 | Self::InsecureParentDirectory { .. }
729 | Self::UnsupportedSchema { .. }
730 | Self::WireVersionMismatch { .. }
731 | Self::Invalid { .. }
732 | Self::KeyTooShort { .. }
733 | Self::InsecurePermissions { .. } => None,
734 }
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn sample_info() -> ConnectionInfo {
743 ConnectionInfo {
744 schema: SCHEMA_VERSION,
745 wire_version: None,
746 endpoints: vec![Endpoint {
747 host: "127.0.0.1".to_owned(),
748 port: 8799,
749 }],
750 key: vec![0xABu8; KEY_LEN],
751 daemon_id: [0x11u8; DAEMON_ID_LEN],
752 pid: 4242,
753 daemon_ver: "subc-test".to_owned(),
754 }
755 }
756
757 fn unique_temp_path() -> PathBuf {
758 let mut suffix = [0u8; 8];
759 getrandom::getrandom(&mut suffix).expect("random suffix");
760 let mut name = String::from("subc-connfile-test-");
761 for byte in suffix {
762 name.push_str(&format!("{byte:02x}"));
763 }
764 name.push_str(".json");
765 std::env::temp_dir().join(name)
766 }
767
768 fn unique_temp_dir(label: &str) -> PathBuf {
769 let path = unique_temp_path().with_extension(label);
770 fs::create_dir_all(&path).expect("create test directory");
771 path
772 }
773
774 #[cfg(unix)]
783 #[test]
784 fn a_group_writable_ancestor_refuses_and_the_same_tree_without_the_bit_publishes() {
785 use std::os::unix::fs::PermissionsExt;
786
787 for (ancestor_mode, expect_refusal) in [(0o770, true), (0o750, false)] {
788 let root = unique_temp_dir(&format!("ancestor-{ancestor_mode:o}"));
789 let ancestor = root.join("ancestor");
790 let leaf = ancestor.join("run");
791 fs::create_dir_all(&leaf).expect("create leaf");
792 fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700))
793 .expect("tighten the leaf so only the ancestor differs");
794 fs::set_permissions(&ancestor, fs::Permissions::from_mode(ancestor_mode))
795 .expect("set ancestor mode");
796
797 let result = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
798
799 match (expect_refusal, result) {
800 (true, Err(ConnectionFileError::InsecureParentDirectory { component, mode })) => {
801 let expected = ancestor.canonicalize().unwrap_or_else(|_| ancestor.clone());
802 assert_eq!(component, expected);
803 assert_eq!(mode & 0o020, 0o020, "the group bit is what refused");
804 }
805 (true, other) => panic!("a group-writable ancestor must refuse, got {other:?}"),
806 (false, Ok(())) => {}
807 (false, other) => panic!("0o750 is not writable by another user: {other:?}"),
808 }
809
810 let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
811 let _ = fs::remove_dir_all(root);
812 }
813 }
814
815 #[cfg(unix)]
819 #[test]
820 fn a_sticky_world_writable_ancestor_publishes() {
821 use std::os::unix::fs::PermissionsExt;
822
823 let root = unique_temp_dir("ancestor-sticky");
824 let ancestor = root.join("sticky");
825 let leaf = ancestor.join("run");
826 fs::create_dir_all(&leaf).expect("create leaf");
827 fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700)).expect("tighten leaf");
828 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o1777)).expect("sticky 1777");
829
830 let published = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
831 assert!(
832 published.is_ok(),
833 "a sticky 1777 ancestor is /tmp's own shape and must publish: {published:?}"
834 );
835
836 let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
837 let _ = fs::remove_dir_all(root);
838 }
839
840 #[cfg(unix)]
843 #[test]
844 fn an_absent_parent_is_created_owner_only() {
845 use std::os::unix::fs::PermissionsExt;
846
847 let root = unique_temp_dir("absent-parent");
848 let parent = root.join("run");
849 assert!(!parent.exists(), "fixture must start with no parent");
850
851 write_atomic(parent.join(CONNECTION_FILE_NAME), &sample_info()).expect("publishes");
852
853 let mode = fs::metadata(&parent)
854 .expect("parent exists")
855 .permissions()
856 .mode()
857 & 0o777;
858 assert_eq!(mode, 0o700, "an absent parent is created owner-only");
859
860 let _ = fs::remove_dir_all(root);
861 }
862
863 fn prod_connection_file(home: &Path) -> PathBuf {
864 let mut path = home.to_path_buf();
865 for part in PROD_CONNECTION_RELATIVE_PATH {
866 path.push(part);
867 }
868 path
869 }
870
871 #[test]
872 fn an_explicit_path_is_the_only_discovery_candidate() {
873 let explicit = PathBuf::from("/rig/explicit.json");
874 let env_named = OsStr::new("/rig/from-env.json");
875
876 assert_eq!(
877 discovery_candidates(Some(&explicit), Some(env_named)),
878 vec![explicit],
879 "the caller's explicit override must exclude every fallback"
880 );
881 }
882
883 #[test]
884 fn set_and_wrong_environment_path_fails_without_fallback() {
885 let root = unique_temp_dir("env-exclusive");
886 let runtime = root.join("runtime");
887 let home = root.join("home");
888 let temp = root.join("temp");
889 let named = root.join("missing-rig.json");
890 let production = prod_connection_file(&home);
891 fs::create_dir_all(production.parent().expect("production parent"))
892 .expect("create production parent");
893 write_atomic(&production, &sample_info()).expect("write discoverable production file");
894
895 assert_eq!(
896 discovery_candidates(None, Some(named.as_os_str())),
897 vec![named.clone()],
898 "a named connection file must not be followed by discovery paths"
899 );
900 let candidates = discovery_candidates_with_environment(
901 None,
902 Some(named.as_os_str()),
903 Some(runtime.as_os_str()),
904 Some(home.as_os_str()),
905 &temp,
906 );
907 let error = discover_candidates(candidates)
908 .expect_err("set-and-wrong SUBC_CONNECTION_FILE must fail rather than use production");
909
910 assert_eq!(
911 error.tried,
912 vec![TriedCandidate {
913 path: named.clone(),
914 reason: "not found".to_owned(),
915 }],
916 "the named rig path must be the only attempted file"
917 );
918 assert!(
919 error.to_string().contains(&named.display().to_string()),
920 "the failure must name the operator-selected rig path"
921 );
922 fs::remove_dir_all(root).expect("remove test directory");
923 }
924
925 #[test]
926 fn empty_environment_paths_are_unset_and_fallback_candidates_are_absolute() {
927 let root = unique_temp_dir("empty-candidates");
928 let runtime = root.join("runtime");
929 let home = root.join("home");
930 let temp = root.join("temp");
931 let empty = OsStr::new("");
932
933 let without_named_override = discovery_candidates_with_environment(
934 None,
935 None,
936 Some(runtime.as_os_str()),
937 Some(home.as_os_str()),
938 &temp,
939 );
940 let with_empty_named_override = discovery_candidates_with_environment(
941 None,
942 Some(empty),
943 Some(runtime.as_os_str()),
944 Some(home.as_os_str()),
945 &temp,
946 );
947 assert_eq!(with_empty_named_override, without_named_override);
948
949 let without_runtime =
950 discovery_candidates_with_environment(None, None, None, Some(home.as_os_str()), &temp);
951 let with_empty_runtime = discovery_candidates_with_environment(
952 None,
953 None,
954 Some(empty),
955 Some(home.as_os_str()),
956 &temp,
957 );
958 assert_eq!(with_empty_runtime, without_runtime);
959 assert!(
960 with_empty_named_override.iter().all(|path| path.is_absolute())
961 && with_empty_runtime.iter().all(|path| path.is_absolute()),
962 "every fallback candidate must be absolute: {with_empty_named_override:?} {with_empty_runtime:?}"
963 );
964
965 fs::remove_dir_all(root).expect("remove test directory");
966 }
967
968 #[test]
969 fn discovery_without_overrides_keeps_three_rung_order_and_deduplicates() {
970 let root = unique_temp_dir("candidate-order");
971 let runtime = root.join("runtime");
972 let home = root.join("home");
973 let temp = root.join("temp");
974
975 let candidates = discovery_candidates_with_environment(
976 None,
977 None,
978 Some(runtime.as_os_str()),
979 Some(home.as_os_str()),
980 &temp,
981 );
982 assert_eq!(
983 candidates,
984 vec![
985 runtime.join(CONNECTION_FILE_NAME),
986 prod_connection_file(&home),
987 temp.join(format!("subc-{}.connection.json", user_connection_token())),
988 ],
989 "readers must try runtime, production, then the per-user temp fallback"
990 );
991
992 let production = prod_connection_file(&home);
993 let production_dir = production.parent().expect("production directory");
994 let deduplicated = discovery_candidates_with_environment(
995 None,
996 None,
997 Some(production_dir.as_os_str()),
998 Some(home.as_os_str()),
999 &temp,
1000 );
1001 assert_eq!(
1002 deduplicated,
1003 vec![
1004 production,
1005 temp.join(format!("subc-{}.connection.json", user_connection_token())),
1006 ],
1007 "one path reached through two rungs must only be tried once"
1008 );
1009 fs::remove_dir_all(root).expect("remove test directory");
1010 }
1011
1012 #[test]
1013 fn discover_on_a_temp_home_returns_the_parsed_production_file() {
1014 const CHILD_MARKER: &str = "SUBC_TRANSPORT_DISCOVERY_CHILD_EXPECTED";
1015 if let Some(expected) = env::var_os(CHILD_MARKER) {
1016 let expected = PathBuf::from(expected);
1017 let discovered = discover(None).expect("discover production connection file");
1018 assert_eq!(discovered.path, expected);
1019 assert_eq!(discovered.info, sample_info());
1020 return;
1021 }
1022
1023 let root = unique_temp_dir("discover-home");
1024 let home = root.join("home");
1025 let temp = root.join("temp");
1026 fs::create_dir_all(&temp).expect("create child temp directory");
1027 let production = prod_connection_file(&home);
1028 fs::create_dir_all(production.parent().expect("production parent"))
1029 .expect("create production parent");
1030 write_atomic(&production, &sample_info()).expect("write production connection file");
1031
1032 let output = process::Command::new(env::current_exe().expect("current test executable"))
1035 .args([
1036 "--exact",
1037 "connection_file::tests::discover_on_a_temp_home_returns_the_parsed_production_file",
1038 "--nocapture",
1039 ])
1040 .env(CHILD_MARKER, &production)
1041 .env_remove("SUBC_CONNECTION_FILE")
1042 .env_remove("XDG_RUNTIME_DIR")
1043 .env("HOME", &home)
1044 .env("TMPDIR", &temp)
1045 .env("TMP", &temp)
1046 .env("TEMP", &temp)
1047 .output()
1048 .expect("run isolated discovery child");
1049 assert!(
1050 output.status.success(),
1051 "discovery child failed\nstdout:\n{}\nstderr:\n{}",
1052 String::from_utf8_lossy(&output.stdout),
1053 String::from_utf8_lossy(&output.stderr)
1054 );
1055 fs::remove_dir_all(root).expect("remove test directory");
1056 }
1057
1058 #[test]
1059 fn write_atomic_sweeps_stale_temps_and_spares_recent_and_unrelated_files() {
1060 let dir = std::env::temp_dir().join(format!("subc-sweep-{}", process::id()));
1061 fs::create_dir_all(&dir).expect("create dir");
1062 let target = dir.join("subc-connection.json");
1063
1064 let stale = dir.join(".subc-connection.json.99999.deadbeef.tmp");
1066 fs::write(&stale, b"stranded").expect("write stale");
1067 let old = SystemTime::now() - Duration::from_secs(3600);
1068 File::options()
1069 .write(true)
1070 .open(&stale)
1071 .expect("open stale")
1072 .set_modified(old)
1073 .expect("backdate stale");
1074
1075 let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
1078 fs::write(&recent, b"in flight").expect("write recent");
1079
1080 let unrelated = dir.join("unrelated.txt");
1082 fs::write(&unrelated, b"not ours").expect("write unrelated");
1083 File::options()
1084 .write(true)
1085 .open(&unrelated)
1086 .expect("open unrelated")
1087 .set_modified(old)
1088 .expect("backdate unrelated");
1089
1090 write_atomic(&target, &sample_info()).expect("publish");
1091
1092 assert!(!stale.exists(), "a stale temp must be swept");
1093 assert!(
1094 recent.exists(),
1095 "a recent temp may belong to an in-flight publish and must be spared"
1096 );
1097 assert!(
1098 unrelated.exists(),
1099 "age alone must not condemn a file that is not one of our temps"
1100 );
1101 assert!(target.exists(), "the publish itself must still land");
1102
1103 let _ = fs::remove_dir_all(&dir);
1104 }
1105
1106 #[test]
1107 fn debug_redacts_key_bytes() {
1108 let info = sample_info();
1109 let rendered = format!("{info:?}");
1110 assert!(
1111 rendered.contains("redacted"),
1112 "Debug must mark the key as redacted: {rendered}"
1113 );
1114 assert!(
1116 !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
1117 "Debug must not leak raw key bytes: {rendered}"
1118 );
1119 }
1120
1121 #[test]
1122 fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
1123 let mut unsupported_schema = sample_info();
1124 unsupported_schema.schema = SCHEMA_VERSION + 1;
1125 let before = unsupported_schema.clone();
1126 let err = unsupported_schema
1127 .validate()
1128 .expect_err("unsupported schema must be rejected");
1129 assert!(matches!(
1130 err,
1131 ConnectionFileError::UnsupportedSchema {
1132 schema,
1133 supported: SCHEMA_VERSION,
1134 } if schema == SCHEMA_VERSION + 1
1135 ));
1136 assert_eq!(unsupported_schema, before, "validate must not mutate input");
1137
1138 let mut empty_endpoints = sample_info();
1139 empty_endpoints.endpoints.clear();
1140 let before = empty_endpoints.clone();
1141 let err = empty_endpoints
1142 .validate()
1143 .expect_err("empty endpoint list must be rejected");
1144 assert!(matches!(
1145 err,
1146 ConnectionFileError::Invalid { ref reason }
1147 if reason == "connection file must include at least one endpoint"
1148 ));
1149 assert_eq!(empty_endpoints, before, "validate must not mutate input");
1150
1151 let mut short_key = sample_info();
1152 short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
1153 let before = short_key.clone();
1154 let err = short_key
1155 .validate()
1156 .expect_err("short key must be rejected");
1157 assert!(matches!(
1158 err,
1159 ConnectionFileError::KeyTooShort {
1160 len,
1161 min: MIN_KEY_LEN,
1162 } if len == MIN_KEY_LEN - 1
1163 ));
1164 assert_eq!(short_key, before, "validate must not mutate input");
1165 }
1166
1167 #[test]
1168 fn optional_wire_version_round_trips() {
1169 let path = unique_temp_path();
1170 let legacy = sample_info();
1171 write_atomic(&path, &legacy).expect("write legacy connection file");
1172 let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
1173 assert!(!legacy_json.contains("wire_version"));
1174 assert_eq!(
1175 read_for_client(&path).expect("legacy file remains readable"),
1176 legacy
1177 );
1178
1179 let mut current = sample_info();
1180 current.wire_version = Some(PROTOCOL_VERSION);
1181 write_atomic(&path, ¤t).expect("write current connection file");
1182 let current_json = fs::read_to_string(&path).expect("read current connection file");
1183 let current_json: serde_json::Value =
1184 serde_json::from_str(¤t_json).expect("parse current connection file");
1185 assert_eq!(
1186 current_json["wire_version"].as_u64(),
1187 Some(u64::from(PROTOCOL_VERSION))
1188 );
1189 assert_eq!(
1190 read_for_client(&path).expect("current file is readable"),
1191 current
1192 );
1193 let _ = fs::remove_file(&path);
1194 }
1195
1196 #[test]
1197 fn read_for_client_rejects_mismatched_wire_version() {
1198 let path = unique_temp_path();
1199 let mut info = sample_info();
1200 let file_version = PROTOCOL_VERSION + 1;
1201 info.wire_version = Some(file_version);
1202 write_atomic(&path, &info).expect("write mismatched connection file");
1203
1204 let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
1205 assert!(matches!(
1206 err,
1207 ConnectionFileError::WireVersionMismatch { file, supported }
1208 if file == file_version && supported == PROTOCOL_VERSION
1209 ));
1210 let rendered = err.to_string();
1211 assert!(rendered.contains(&file_version.to_string()));
1212 assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
1213 assert!(rendered.contains("binary must be upgraded"));
1214 let _ = fs::remove_file(&path);
1215 }
1216
1217 #[cfg(unix)]
1218 #[test]
1219 fn read_rejects_group_or_world_readable_file() {
1220 use std::os::unix::fs::PermissionsExt;
1221
1222 let path = unique_temp_path();
1223 write_atomic(&path, &sample_info()).expect("write owner-only file");
1224 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
1226
1227 let err = read(&path).expect_err("group/world-readable key file must be rejected");
1228 assert!(
1229 matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
1230 "expected InsecurePermissions, got {err:?}"
1231 );
1232 let _ = fs::remove_file(&path);
1233 }
1234}