1use std::{
2 env,
3 error::Error,
4 ffi::{OsStr, OsString},
5 fmt,
6 fs::{self, File, OpenOptions},
7 io::{self, Read, 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)]
282fn refuse_writable_ancestor(parent: &Path) -> Result<(), ConnectionFileError> {
283 refuse_writable_ancestor_for_uid(parent, rustix::process::geteuid().as_raw())
284}
285
286#[cfg(unix)]
287fn refuse_writable_ancestor_for_uid(parent: &Path, uid: u32) -> Result<(), ConnectionFileError> {
288 use std::os::unix::fs::{MetadataExt, PermissionsExt};
289
290 const GROUP_OR_WORLD_WRITABLE: u32 = 0o022;
291 const STICKY: u32 = 0o1000;
292
293 let Ok(resolved) = fs::canonicalize(parent) else {
297 return Ok(());
298 };
299
300 let mut component = resolved.as_path();
301 loop {
302 if let Ok(metadata) = fs::metadata(component) {
311 if metadata.uid() != uid && metadata.uid() != 0 {
312 return Err(ConnectionFileError::Invalid {
313 reason: format!(
314 "connection-file ancestor {} is owned by uid {}, expected effective uid {uid} or root",
315 component.display(), metadata.uid()
316 ),
317 });
318 }
319 let mode = metadata.permissions().mode();
320 if mode & GROUP_OR_WORLD_WRITABLE != 0 && mode & STICKY == 0 {
321 return Err(ConnectionFileError::InsecureParentDirectory {
322 component: component.to_path_buf(),
323 mode: mode & 0o7777,
324 });
325 }
326 }
327 match component.parent() {
328 Some(next) => component = next,
329 None => return Ok(()),
330 }
331 }
332}
333
334#[cfg(not(unix))]
335fn refuse_writable_ancestor(_parent: &Path) -> Result<(), ConnectionFileError> {
336 Ok(())
339}
340
341fn sweep_stale_temps(parent: &Path, file_name: &std::ffi::OsStr) {
350 const STALE_AFTER: Duration = Duration::from_secs(600);
351
352 let prefix = format!(".{}.", file_name.to_string_lossy());
353 let Ok(entries) = fs::read_dir(parent) else {
354 return;
355 };
356 for entry in entries.flatten() {
357 let name = entry.file_name();
358 let name = name.to_string_lossy();
359 if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
360 continue;
361 }
362 let stale = entry
363 .metadata()
364 .and_then(|meta| meta.modified())
365 .map(|modified| {
366 SystemTime::now()
367 .duration_since(modified)
368 .is_ok_and(|age| age >= STALE_AFTER)
369 })
370 .unwrap_or(false);
371 if stale {
372 let _ = fs::remove_file(entry.path());
373 }
374 }
375}
376
377pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
378 let path = path.as_ref();
379 let mut file = File::open(path).map_err(|source| ConnectionFileError::Io {
380 op: "open",
381 path: path.to_path_buf(),
382 source,
383 })?;
384 verify_owner_only(path, &file)?;
387 let mut bytes = Vec::new();
388 file.read_to_end(&mut bytes)
389 .map_err(|source| ConnectionFileError::Io {
390 op: "read",
391 path: path.to_path_buf(),
392 source,
393 })?;
394 let info: ConnectionInfo =
395 serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
396 path: path.to_path_buf(),
397 source,
398 })?;
399 info.validate()?;
400 Ok(info)
401}
402
403pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
406 let info = read(path)?;
407 info.validate_wire_version(PROTOCOL_VERSION)?;
408 Ok(info)
409}
410
411pub fn discovery_candidates(explicit: Option<&Path>, env_named: Option<&OsStr>) -> Vec<PathBuf> {
416 let runtime_dir = non_empty_os_var("XDG_RUNTIME_DIR");
417 let home = non_empty_os_var("HOME");
418 discovery_candidates_with_environment(
419 explicit,
420 env_named,
421 runtime_dir.as_deref(),
422 home.as_deref(),
423 &env::temp_dir(),
424 )
425}
426
427pub fn discover(explicit: Option<&Path>) -> Result<Discovered, DiscoveryError> {
431 let env_named = non_empty_os_var("SUBC_CONNECTION_FILE");
432 discover_candidates(discovery_candidates(explicit, env_named.as_deref()))
433}
434
435fn discovery_candidates_with_environment(
436 explicit: Option<&Path>,
437 env_named: Option<&OsStr>,
438 runtime_dir: Option<&OsStr>,
439 home: Option<&OsStr>,
440 temp_dir: &Path,
441) -> Vec<PathBuf> {
442 if let Some(path) = explicit {
443 return vec![path.to_path_buf()];
444 }
445
446 let env_named = env_named.filter(|value| !value.is_empty());
447 let runtime_dir = runtime_dir.filter(|value| !value.is_empty());
448
449 if let Some(only) = env_named {
462 return vec![PathBuf::from(only)];
463 }
464
465 let mut candidates = Vec::new();
466 if let Some(runtime_dir) = runtime_dir {
467 push_unique(
468 &mut candidates,
469 PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
470 );
471 }
472 if let Some(home) = home {
473 let mut path = PathBuf::from(home);
474 for part in PROD_CONNECTION_RELATIVE_PATH {
475 path.push(part);
476 }
477 push_unique(&mut candidates, path);
478 }
479 push_unique(
480 &mut candidates,
481 temp_dir.join(format!("subc-{}.connection.json", user_connection_token())),
482 );
483 candidates
484}
485
486fn discover_candidates(candidates: Vec<PathBuf>) -> Result<Discovered, DiscoveryError> {
487 let mut tried = Vec::new();
488 for path in candidates {
489 match read_for_client(&path) {
490 Ok(info) => return Ok(Discovered { path, info }),
491 Err(source) => tried.push(TriedCandidate {
492 path,
493 reason: discovery_reason(&source),
494 }),
495 }
496 }
497 Err(DiscoveryError { tried })
498}
499
500fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
501 if !paths.iter().any(|existing| existing == &path) {
502 paths.push(path);
503 }
504}
505
506fn non_empty_os_var(key: &str) -> Option<OsString> {
507 let value = env::var_os(key)?;
508 if value.is_empty() {
509 None
510 } else {
511 Some(value)
512 }
513}
514
515fn discovery_reason(source: &ConnectionFileError) -> String {
516 match source {
517 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => {
518 "not found".to_string()
519 }
520 other => other.to_string(),
521 }
522}
523
524pub fn user_connection_token() -> String {
529 #[cfg(unix)]
533 {
534 rustix::process::getuid().as_raw().to_string()
535 }
536
537 #[cfg(not(unix))]
538 {
539 for key in ["USER", "USERNAME", "HOME", "USERPROFILE"] {
540 if let Some(value) = non_empty_os_var(key) {
541 return sanitize_token(&value.to_string_lossy());
542 }
543 }
544
545 "unknown".to_string()
546 }
547}
548
549#[cfg(not(unix))]
550fn sanitize_token(raw: &str) -> String {
551 let mut token = String::new();
552 for ch in raw.chars() {
553 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
554 token.push(ch);
555 } else {
556 token.push('_');
557 }
558 }
559 if token.is_empty() {
560 "unknown".to_string()
561 } else {
562 token
563 }
564}
565
566#[cfg(unix)]
567fn verify_owner_only(path: &Path, file: &File) -> Result<(), ConnectionFileError> {
568 let meta = file.metadata().map_err(|source| ConnectionFileError::Io {
569 op: "fstat",
570 path: path.to_path_buf(),
571 source,
572 })?;
573 verify_owner_only_for_uid(path, &meta, rustix::process::geteuid().as_raw())
574}
575
576#[cfg(unix)]
577fn verify_owner_only_for_uid(
578 path: &Path,
579 meta: &fs::Metadata,
580 uid: u32,
581) -> Result<(), ConnectionFileError> {
582 use std::os::unix::fs::{MetadataExt, PermissionsExt};
583 if meta.uid() != uid {
586 return Err(ConnectionFileError::Invalid {
587 reason: format!(
588 "connection file {} is owned by uid {}, expected effective uid {uid}",
589 path.display(),
590 meta.uid()
591 ),
592 });
593 }
594 let mode = meta.permissions().mode();
595 if mode & 0o077 != 0 {
597 return Err(ConnectionFileError::InsecurePermissions {
598 path: path.to_path_buf(),
599 mode: mode & 0o777,
600 });
601 }
602 Ok(())
603}
604
605#[cfg(not(unix))]
606fn verify_owner_only(_path: &Path, _file: &File) -> Result<(), ConnectionFileError> {
607 Ok(())
611}
612
613pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
614 let mut key = vec![0u8; KEY_LEN];
615 getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
616 Ok(key)
617}
618
619pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
620 let mut daemon_id = [0u8; DAEMON_ID_LEN];
621 getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
622 Ok(daemon_id)
623}
624
625fn write_atomic_inner(
626 path: &Path,
627 temp_path: &Path,
628 info: &ConnectionInfo,
629) -> Result<(), ConnectionFileError> {
630 let json =
631 serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
632 path: path.to_path_buf(),
633 source,
634 })?;
635
636 {
637 let mut file =
638 open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
639 op: "create_temp",
640 path: temp_path.to_path_buf(),
641 source,
642 })?;
643 file.write_all(&json)
644 .and_then(|()| file.sync_all())
645 .map_err(|source| ConnectionFileError::Io {
646 op: "write_temp",
647 path: temp_path.to_path_buf(),
648 source,
649 })?;
650 }
651
652 fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
653 op: "rename",
654 path: path.to_path_buf(),
655 source,
656 })?;
657 Ok(())
658}
659
660fn open_owner_only_new(path: &Path) -> io::Result<File> {
661 let mut options = OpenOptions::new();
662 options.write(true).create_new(true);
663 #[cfg(unix)]
664 {
665 use std::os::unix::fs::OpenOptionsExt;
666 options.mode(0o600);
667 }
668 #[cfg(windows)]
669 {
670 }
682 options.open(path)
683}
684
685fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
686 let mut suffix = [0u8; 16];
687 getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
688 let file_name = file_name.to_string_lossy();
689 Ok(parent.join(format!(
690 ".{file_name}.{}.{}.tmp",
691 process::id(),
692 hex(&suffix)
693 )))
694}
695
696fn hex(bytes: &[u8]) -> String {
697 const HEX: &[u8; 16] = b"0123456789abcdef";
698 let mut out = String::with_capacity(bytes.len() * 2);
699 for byte in bytes {
700 out.push(HEX[(byte >> 4) as usize] as char);
701 out.push(HEX[(byte & 0x0f) as usize] as char);
702 }
703 out
704}
705
706impl fmt::Display for ConnectionFileError {
707 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
708 match self {
709 Self::MissingParent { path } => {
710 write!(f, "connection file path has no parent: {}", path.display())
711 }
712 Self::MissingFileName { path } => {
713 write!(
714 f,
715 "connection file path has no file name: {}",
716 path.display()
717 )
718 }
719 Self::Io { op, path, source } => write!(
720 f,
721 "connection file {op} failed for {}: {source}",
722 path.display()
723 ),
724 Self::JsonRead { path, source } => write!(
725 f,
726 "connection file JSON read failed for {}: {source}",
727 path.display()
728 ),
729 Self::JsonWrite { path, source } => write!(
730 f,
731 "connection file JSON write failed for {}: {source}",
732 path.display()
733 ),
734 Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
735 Self::UnsupportedSchema { schema, supported } => write!(
736 f,
737 "unsupported connection file schema {schema}; expected {supported}"
738 ),
739 Self::WireVersionMismatch { file, supported } => write!(
740 f,
741 "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
742 ),
743 Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
744 Self::KeyTooShort { len, min } => write!(
745 f,
746 "connection file key is too short: {len} bytes, need at least {min}"
747 ),
748 Self::InsecurePermissions { path, mode } => write!(
749 f,
750 "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
751 path.display()
752 ),
753 Self::InsecureParentDirectory { component, mode } => write!(
754 f,
755 "refusing to publish the connection file: ancestor {} is mode {mode:#o}, \
756 which lets another user replace the file regardless of its own 0600 mode; \
757 this is a misconfiguration check, not a defence against a same-uid caller",
758 component.display()
759 ),
760 }
761 }
762}
763
764impl Error for ConnectionFileError {
765 fn source(&self) -> Option<&(dyn Error + 'static)> {
766 match self {
767 Self::Io { source, .. } => Some(source),
768 Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
769 Self::Random(_) => None,
770 Self::MissingParent { .. }
771 | Self::MissingFileName { .. }
772 | Self::InsecureParentDirectory { .. }
773 | Self::UnsupportedSchema { .. }
774 | Self::WireVersionMismatch { .. }
775 | Self::Invalid { .. }
776 | Self::KeyTooShort { .. }
777 | Self::InsecurePermissions { .. } => None,
778 }
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use subc_test_support::TestTempDir;
786
787 fn sample_info() -> ConnectionInfo {
788 ConnectionInfo {
789 schema: SCHEMA_VERSION,
790 wire_version: None,
791 endpoints: vec![Endpoint {
792 host: "127.0.0.1".to_owned(),
793 port: 8799,
794 }],
795 key: vec![0xABu8; KEY_LEN],
796 daemon_id: [0x11u8; DAEMON_ID_LEN],
797 pid: 4242,
798 daemon_ver: "subc-test".to_owned(),
799 }
800 }
801
802 fn unique_temp_path() -> PathBuf {
803 let mut suffix = [0u8; 8];
804 getrandom::getrandom(&mut suffix).expect("random suffix");
805 let mut name = String::from("subc-connfile-test-");
806 for byte in suffix {
807 name.push_str(&format!("{byte:02x}"));
808 }
809 name.push_str(".json");
810 std::env::temp_dir().join(name)
811 }
812
813 fn unique_temp_dir(label: &str) -> TestTempDir {
814 TestTempDir::new(label)
815 }
816
817 #[cfg(unix)]
818 #[test]
819 fn a_foreign_owned_ancestor_refuses_even_with_mode_0755() {
820 use std::os::unix::fs::{MetadataExt, PermissionsExt};
821 let root = unique_temp_dir("foreign-ancestor");
822 let parent = root.join("run");
823 fs::create_dir(&parent).unwrap();
824 fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
825 if fs::metadata(&parent).unwrap().uid() == 0 {
828 assert!(process::Command::new("chown")
829 .args(["65534"])
830 .arg(&parent)
831 .status()
832 .unwrap()
833 .success());
834 }
835 assert_ne!(fs::metadata(&parent).unwrap().uid(), 0);
836 assert!(
837 refuse_writable_ancestor_for_uid(&parent, 0).is_err(),
838 "0755 does not prevent its owner replacing the key"
839 );
840 if rustix::process::geteuid().as_raw() != 0 {
841 assert!(
842 refuse_writable_ancestor(&parent).is_ok(),
843 "the same directory is safe for its actual owner"
844 );
845 }
846 }
847
848 #[cfg(unix)]
849 #[test]
850 fn owner_only_file_validation_refuses_a_different_effective_uid() {
851 use std::os::unix::fs::{MetadataExt, PermissionsExt};
852 let root = unique_temp_dir("foreign-file");
853 let path = root.join(CONNECTION_FILE_NAME);
854 write_atomic(&path, &sample_info()).unwrap();
855 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
856 let owner = fs::metadata(&path).unwrap().uid();
857 let meta = fs::metadata(&path).unwrap();
858 assert!(verify_owner_only_for_uid(&path, &meta, owner).is_ok());
859 let different_uid = if owner == 0 { 1 } else { 0 };
860 assert!(
861 matches!(
862 verify_owner_only_for_uid(&path, &meta, different_uid),
863 Err(ConnectionFileError::Invalid { .. })
864 ),
865 "0600 is not proof that the file belongs to the reader"
866 );
867 assert_eq!(read(&path).unwrap(), sample_info());
868 }
869
870 #[cfg(unix)]
879 #[test]
880 fn a_group_writable_ancestor_refuses_and_the_same_tree_without_the_bit_publishes() {
881 use std::os::unix::fs::PermissionsExt;
882
883 for (ancestor_mode, expect_refusal) in [(0o770, true), (0o750, false)] {
884 let root = unique_temp_dir(&format!("ancestor-{ancestor_mode:o}"));
885 let ancestor = root.join("ancestor");
886 let leaf = ancestor.join("run");
887 fs::create_dir_all(&leaf).expect("create leaf");
888 fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700))
889 .expect("tighten the leaf so only the ancestor differs");
890 fs::set_permissions(&ancestor, fs::Permissions::from_mode(ancestor_mode))
891 .expect("set ancestor mode");
892
893 let result = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
894
895 match (expect_refusal, result) {
896 (true, Err(ConnectionFileError::InsecureParentDirectory { component, mode })) => {
897 let expected = ancestor.canonicalize().unwrap_or_else(|_| ancestor.clone());
898 assert_eq!(component, expected);
899 assert_eq!(mode & 0o020, 0o020, "the group bit is what refused");
900 }
901 (true, other) => panic!("a group-writable ancestor must refuse, got {other:?}"),
902 (false, Ok(())) => {}
903 (false, other) => panic!("0o750 is not writable by another user: {other:?}"),
904 }
905
906 let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
907 let _ = fs::remove_dir_all(root);
908 }
909 }
910
911 #[cfg(unix)]
915 #[test]
916 fn a_sticky_world_writable_ancestor_publishes() {
917 use std::os::unix::fs::PermissionsExt;
918
919 let root = unique_temp_dir("ancestor-sticky");
920 let ancestor = root.join("sticky");
921 let leaf = ancestor.join("run");
922 fs::create_dir_all(&leaf).expect("create leaf");
923 fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700)).expect("tighten leaf");
924 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o1777)).expect("sticky 1777");
925
926 let published = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
927 assert!(
928 published.is_ok(),
929 "a sticky 1777 ancestor is /tmp's own shape and must publish: {published:?}"
930 );
931
932 let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
933 let _ = fs::remove_dir_all(root);
934 }
935
936 #[cfg(unix)]
939 #[test]
940 fn an_absent_parent_is_created_owner_only() {
941 use std::os::unix::fs::PermissionsExt;
942
943 let root = unique_temp_dir("absent-parent");
944 let parent = root.join("run");
945 assert!(!parent.exists(), "fixture must start with no parent");
946
947 write_atomic(parent.join(CONNECTION_FILE_NAME), &sample_info()).expect("publishes");
948
949 let mode = fs::metadata(&parent)
950 .expect("parent exists")
951 .permissions()
952 .mode()
953 & 0o777;
954 assert_eq!(mode, 0o700, "an absent parent is created owner-only");
955
956 let _ = fs::remove_dir_all(root);
957 }
958
959 fn prod_connection_file(home: &Path) -> PathBuf {
960 let mut path = home.to_path_buf();
961 for part in PROD_CONNECTION_RELATIVE_PATH {
962 path.push(part);
963 }
964 path
965 }
966
967 #[test]
968 fn an_explicit_path_is_the_only_discovery_candidate() {
969 let explicit = PathBuf::from("/rig/explicit.json");
970 let env_named = OsStr::new("/rig/from-env.json");
971
972 assert_eq!(
973 discovery_candidates(Some(&explicit), Some(env_named)),
974 vec![explicit],
975 "the caller's explicit override must exclude every fallback"
976 );
977 }
978
979 #[test]
980 fn set_and_wrong_environment_path_fails_without_fallback() {
981 let root = unique_temp_dir("env-exclusive");
982 let runtime = root.join("runtime");
983 let home = root.join("home");
984 let temp = root.join("temp");
985 let named = root.join("missing-rig.json");
986 let production = prod_connection_file(&home);
987 fs::create_dir_all(production.parent().expect("production parent"))
988 .expect("create production parent");
989 write_atomic(&production, &sample_info()).expect("write discoverable production file");
990
991 assert_eq!(
992 discovery_candidates(None, Some(named.as_os_str())),
993 vec![named.clone()],
994 "a named connection file must not be followed by discovery paths"
995 );
996 let candidates = discovery_candidates_with_environment(
997 None,
998 Some(named.as_os_str()),
999 Some(runtime.as_os_str()),
1000 Some(home.as_os_str()),
1001 &temp,
1002 );
1003 let error = discover_candidates(candidates)
1004 .expect_err("set-and-wrong SUBC_CONNECTION_FILE must fail rather than use production");
1005
1006 assert_eq!(
1007 error.tried,
1008 vec![TriedCandidate {
1009 path: named.clone(),
1010 reason: "not found".to_owned(),
1011 }],
1012 "the named rig path must be the only attempted file"
1013 );
1014 assert!(
1015 error.to_string().contains(&named.display().to_string()),
1016 "the failure must name the operator-selected rig path"
1017 );
1018 drop(root);
1019 }
1020
1021 #[test]
1022 fn empty_environment_paths_are_unset_and_fallback_candidates_are_absolute() {
1023 let root = unique_temp_dir("empty-candidates");
1024 let runtime = root.join("runtime");
1025 let home = root.join("home");
1026 let temp = root.join("temp");
1027 let empty = OsStr::new("");
1028
1029 let without_named_override = discovery_candidates_with_environment(
1030 None,
1031 None,
1032 Some(runtime.as_os_str()),
1033 Some(home.as_os_str()),
1034 &temp,
1035 );
1036 let with_empty_named_override = discovery_candidates_with_environment(
1037 None,
1038 Some(empty),
1039 Some(runtime.as_os_str()),
1040 Some(home.as_os_str()),
1041 &temp,
1042 );
1043 assert_eq!(with_empty_named_override, without_named_override);
1044
1045 let without_runtime =
1046 discovery_candidates_with_environment(None, None, None, Some(home.as_os_str()), &temp);
1047 let with_empty_runtime = discovery_candidates_with_environment(
1048 None,
1049 None,
1050 Some(empty),
1051 Some(home.as_os_str()),
1052 &temp,
1053 );
1054 assert_eq!(with_empty_runtime, without_runtime);
1055 assert!(
1056 with_empty_named_override.iter().all(|path| path.is_absolute())
1057 && with_empty_runtime.iter().all(|path| path.is_absolute()),
1058 "every fallback candidate must be absolute: {with_empty_named_override:?} {with_empty_runtime:?}"
1059 );
1060
1061 drop(root);
1062 }
1063
1064 #[test]
1065 fn discovery_without_overrides_keeps_three_rung_order_and_deduplicates() {
1066 let root = unique_temp_dir("candidate-order");
1067 let runtime = root.join("runtime");
1068 let home = root.join("home");
1069 let temp = root.join("temp");
1070
1071 let candidates = discovery_candidates_with_environment(
1072 None,
1073 None,
1074 Some(runtime.as_os_str()),
1075 Some(home.as_os_str()),
1076 &temp,
1077 );
1078 assert_eq!(
1079 candidates,
1080 vec![
1081 runtime.join(CONNECTION_FILE_NAME),
1082 prod_connection_file(&home),
1083 temp.join(format!("subc-{}.connection.json", user_connection_token())),
1084 ],
1085 "readers must try runtime, production, then the per-user temp fallback"
1086 );
1087
1088 let production = prod_connection_file(&home);
1089 let production_dir = production.parent().expect("production directory");
1090 let deduplicated = discovery_candidates_with_environment(
1091 None,
1092 None,
1093 Some(production_dir.as_os_str()),
1094 Some(home.as_os_str()),
1095 &temp,
1096 );
1097 assert_eq!(
1098 deduplicated,
1099 vec![
1100 production,
1101 temp.join(format!("subc-{}.connection.json", user_connection_token())),
1102 ],
1103 "one path reached through two rungs must only be tried once"
1104 );
1105 drop(root);
1106 }
1107
1108 #[test]
1109 fn discover_on_a_temp_home_returns_the_parsed_production_file() {
1110 const CHILD_MARKER: &str = "SUBC_TRANSPORT_DISCOVERY_CHILD_EXPECTED";
1111 if let Some(expected) = env::var_os(CHILD_MARKER) {
1112 let expected = PathBuf::from(expected);
1113 let discovered = discover(None).expect("discover production connection file");
1114 assert_eq!(discovered.path, expected);
1115 assert_eq!(discovered.info, sample_info());
1116 return;
1117 }
1118
1119 let root = unique_temp_dir("discover-home");
1120 let home = root.join("home");
1121 let temp = root.join("temp");
1122 fs::create_dir_all(&temp).expect("create child temp directory");
1123 let production = prod_connection_file(&home);
1124 fs::create_dir_all(production.parent().expect("production parent"))
1125 .expect("create production parent");
1126 write_atomic(&production, &sample_info()).expect("write production connection file");
1127
1128 let output = process::Command::new(env::current_exe().expect("current test executable"))
1131 .args([
1132 "--exact",
1133 "connection_file::tests::discover_on_a_temp_home_returns_the_parsed_production_file",
1134 "--nocapture",
1135 ])
1136 .env(CHILD_MARKER, &production)
1137 .env_remove("SUBC_CONNECTION_FILE")
1138 .env_remove("XDG_RUNTIME_DIR")
1139 .env("HOME", &home)
1140 .env("TMPDIR", &temp)
1141 .env("TMP", &temp)
1142 .env("TEMP", &temp)
1143 .output()
1144 .expect("run isolated discovery child");
1145 assert!(
1146 output.status.success(),
1147 "discovery child failed\nstdout:\n{}\nstderr:\n{}",
1148 String::from_utf8_lossy(&output.stdout),
1149 String::from_utf8_lossy(&output.stderr)
1150 );
1151 drop(root);
1152 }
1153
1154 #[test]
1155 fn write_atomic_sweeps_stale_temps_and_spares_recent_and_unrelated_files() {
1156 let dir = TestTempDir::new("subc-sweep");
1157 let target = dir.join("subc-connection.json");
1158
1159 let stale = dir.join(".subc-connection.json.99999.deadbeef.tmp");
1161 fs::write(&stale, b"stranded").expect("write stale");
1162 let old = SystemTime::now() - Duration::from_secs(3600);
1163 File::options()
1164 .write(true)
1165 .open(&stale)
1166 .expect("open stale")
1167 .set_modified(old)
1168 .expect("backdate stale");
1169
1170 let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
1173 fs::write(&recent, b"in flight").expect("write recent");
1174
1175 let unrelated = dir.join("unrelated.txt");
1177 fs::write(&unrelated, b"not ours").expect("write unrelated");
1178 File::options()
1179 .write(true)
1180 .open(&unrelated)
1181 .expect("open unrelated")
1182 .set_modified(old)
1183 .expect("backdate unrelated");
1184
1185 write_atomic(&target, &sample_info()).expect("publish");
1186
1187 assert!(!stale.exists(), "a stale temp must be swept");
1188 assert!(
1189 recent.exists(),
1190 "a recent temp may belong to an in-flight publish and must be spared"
1191 );
1192 assert!(
1193 unrelated.exists(),
1194 "age alone must not condemn a file that is not one of our temps"
1195 );
1196 assert!(target.exists(), "the publish itself must still land");
1197 }
1198
1199 #[test]
1200 fn debug_redacts_key_bytes() {
1201 let info = sample_info();
1202 let rendered = format!("{info:?}");
1203 assert!(
1204 rendered.contains("redacted"),
1205 "Debug must mark the key as redacted: {rendered}"
1206 );
1207 assert!(
1209 !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
1210 "Debug must not leak raw key bytes: {rendered}"
1211 );
1212 }
1213
1214 #[test]
1215 fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
1216 let mut unsupported_schema = sample_info();
1217 unsupported_schema.schema = SCHEMA_VERSION + 1;
1218 let before = unsupported_schema.clone();
1219 let err = unsupported_schema
1220 .validate()
1221 .expect_err("unsupported schema must be rejected");
1222 assert!(matches!(
1223 err,
1224 ConnectionFileError::UnsupportedSchema {
1225 schema,
1226 supported: SCHEMA_VERSION,
1227 } if schema == SCHEMA_VERSION + 1
1228 ));
1229 assert_eq!(unsupported_schema, before, "validate must not mutate input");
1230
1231 let mut empty_endpoints = sample_info();
1232 empty_endpoints.endpoints.clear();
1233 let before = empty_endpoints.clone();
1234 let err = empty_endpoints
1235 .validate()
1236 .expect_err("empty endpoint list must be rejected");
1237 assert!(matches!(
1238 err,
1239 ConnectionFileError::Invalid { ref reason }
1240 if reason == "connection file must include at least one endpoint"
1241 ));
1242 assert_eq!(empty_endpoints, before, "validate must not mutate input");
1243
1244 let mut short_key = sample_info();
1245 short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
1246 let before = short_key.clone();
1247 let err = short_key
1248 .validate()
1249 .expect_err("short key must be rejected");
1250 assert!(matches!(
1251 err,
1252 ConnectionFileError::KeyTooShort {
1253 len,
1254 min: MIN_KEY_LEN,
1255 } if len == MIN_KEY_LEN - 1
1256 ));
1257 assert_eq!(short_key, before, "validate must not mutate input");
1258 }
1259
1260 #[test]
1261 fn optional_wire_version_round_trips() {
1262 let path = unique_temp_path();
1263 let legacy = sample_info();
1264 write_atomic(&path, &legacy).expect("write legacy connection file");
1265 let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
1266 assert!(!legacy_json.contains("wire_version"));
1267 assert_eq!(
1268 read_for_client(&path).expect("legacy file remains readable"),
1269 legacy
1270 );
1271
1272 let mut current = sample_info();
1273 current.wire_version = Some(PROTOCOL_VERSION);
1274 write_atomic(&path, ¤t).expect("write current connection file");
1275 let current_json = fs::read_to_string(&path).expect("read current connection file");
1276 let current_json: serde_json::Value =
1277 serde_json::from_str(¤t_json).expect("parse current connection file");
1278 assert_eq!(
1279 current_json["wire_version"].as_u64(),
1280 Some(u64::from(PROTOCOL_VERSION))
1281 );
1282 assert_eq!(
1283 read_for_client(&path).expect("current file is readable"),
1284 current
1285 );
1286 let _ = fs::remove_file(&path);
1287 }
1288
1289 #[test]
1290 fn read_for_client_rejects_mismatched_wire_version() {
1291 let path = unique_temp_path();
1292 let mut info = sample_info();
1293 let file_version = PROTOCOL_VERSION + 1;
1294 info.wire_version = Some(file_version);
1295 write_atomic(&path, &info).expect("write mismatched connection file");
1296
1297 let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
1298 assert!(matches!(
1299 err,
1300 ConnectionFileError::WireVersionMismatch { file, supported }
1301 if file == file_version && supported == PROTOCOL_VERSION
1302 ));
1303 let rendered = err.to_string();
1304 assert!(rendered.contains(&file_version.to_string()));
1305 assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
1306 assert!(rendered.contains("binary must be upgraded"));
1307 let _ = fs::remove_file(&path);
1308 }
1309
1310 #[cfg(unix)]
1311 #[test]
1312 fn read_rejects_group_or_world_readable_file() {
1313 use std::os::unix::fs::PermissionsExt;
1314
1315 let path = unique_temp_path();
1316 write_atomic(&path, &sample_info()).expect("write owner-only file");
1317 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
1319
1320 let err = read(&path).expect_err("group/world-readable key file must be rejected");
1321 assert!(
1322 matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
1323 "expected InsecurePermissions, got {err:?}"
1324 );
1325 let _ = fs::remove_file(&path);
1326 }
1327}