Skip to main content

subc_transport/
connection_file.rs

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
21/// The daemon's connection-file name. Public so the writer (bootstrap) and
22/// every reader spell it once; three private copies of this literal used to
23/// exist and nothing asserted they agreed.
24pub 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
46// Hand-written so the transport key is never printed. A derived Debug would dump
47// the raw key bytes into any log or panic message that formats a ConnectionInfo.
48impl 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/// A connection file selected by reader-side discovery.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Discovered {
65    pub path: PathBuf,
66    pub info: ConnectionInfo,
67}
68
69/// One connection-file candidate that could not be read or parsed.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TriedCandidate {
72    pub path: PathBuf,
73    pub reason: String,
74}
75
76/// Every connection-file candidate reader-side discovery tried.
77#[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    /// Validates a declared envelope version without rejecting older files that
119    /// omit the additive field.
120    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 temps stranded by an earlier writer before creating our own. The
198    // error path below removes this call's temp, but nothing removes one left by
199    // a process that died BETWEEN create and rename -- and a connection file
200    // carries the daemon's auth key, so a stranded temp is a stale credential
201    // sitting in the runtime directory indefinitely. Owner-only mode means no
202    // other user can read it and the key dies with the daemon that minted it;
203    // the objection is to key material with no owner and no expiry, not to an
204    // active leak.
205    //
206    // Best-effort and non-fatal: publishing must not fail because cleanup could
207    // not remove somebody else's file.
208    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
218/// Create the connection file's parent with owner-only permissions when it is
219/// absent.
220///
221/// The daemon OWNS this directory, so it can make the misconfiguration
222/// unproducible rather than only reporting it — which is strictly stronger than
223/// the check below and is available to us precisely because we choose the path.
224/// A caller that names its own destination has only the check.
225///
226/// An existing directory is left alone: changing modes under an operator is a
227/// bigger act than refusing, and `refuse_writable_ancestor` reports it.
228fn 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/// Refuse to publish key material beneath a directory another user can write.
251///
252/// A LINT AGAINST MISCONFIGURATION, NOT A SECURITY BOUNDARY. It does nothing
253/// against a same-uid adversary, who can read the finished 0600 file anyway. It
254/// catches the cross-uid case, which the same-uid concession does NOT cover: a
255/// group- or world-writable ancestor lets another user UNLINK the 0600 file and
256/// substitute their own, because directory write permission governs create and
257/// unlink rather than the target's mode. The file's own mode does not close it
258/// and neither does its ownership.
259///
260/// EVERY ANCESTOR, UP TO `/`. Stopping at `$HOME` or an XDG base would read the
261/// bound from the environment, so it would be attacker-influenceable and
262/// undefined when unset. It is also incorrect: an attacker who can unlink in ANY
263/// ancestor renames an intermediate directory aside and substitutes their own
264/// tree, so a 0700 leaf under a 0777 grandparent protects nothing. Every
265/// component or the guarantee does not compose.
266///
267/// CANONICALISE FIRST. An unresolved walk checks the modes of a path that is not
268/// the one we write through: a symlink component pointing somewhere permissive
269/// defeats the walk while every individual `stat` passes.
270///
271/// STICKY EXEMPTS. `/tmp` and `/Users/Shared` are 1777 by design; without the
272/// exemption this fires on correctly-configured systems, and a check that
273/// refuses healthy configuration gets disabled — after which it protects nothing
274/// at all.
275#[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    // A parent that cannot be canonicalised is reported by the write itself with
283    // its own io::Error; refusing here would replace a precise errno with a
284    // permissions verdict about a path we could not resolve.
285    let Ok(resolved) = fs::canonicalize(parent) else {
286        return Ok(());
287    };
288
289    let mut component = resolved.as_path();
290    loop {
291        // A component whose metadata cannot be read is SKIPPED, not refused, and
292        // the silence is deliberate. Canonicalising above already required
293        // traverse permission on every component, so a failure here is close to
294        // unreachable and is a transient io error rather than evidence about
295        // permissions; refusing on it would convert that error into a confident
296        // verdict about a mode we never observed, which is the same trade the
297        // canonicalise arm declines. Argued at the site because an UNARGUED
298        // fail-open is the one a later reader tightens into a refusal.
299        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    // Windows ACLs are not a mode bitmask and the Unix reasoning does not carry.
318    // Stated rather than silently skipped so the absence is a decision.
319    Ok(())
320}
321
322/// Remove `.<file_name>.<pid>.<hex>.tmp` siblings older than ten minutes.
323///
324/// AGE IS THE SOLE PREDICATE. Testing whether the embedded pid is alive reads
325/// false-positive on exactly the oldest files, because pid numbers are recycled:
326/// an unrelated long-lived process inherits the number and the stalest temp
327/// looks owned. That failure direction resembles caution, which is why nobody
328/// investigates the survivors. Ten minutes is far longer than the window this
329/// guards, which spans two syscalls.
330fn 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    // Refuse to trust a key from a file other local users can read. The key is
361    // published owner-only (0600); if the on-disk file is group/world-accessible
362    // the secret has leaked and the daemon it points at can't be trusted.
363    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
378/// Reads connection information for a client and rejects a declared envelope
379/// version this binary cannot decode before a TCP connection is attempted.
380pub 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
386/// The paths a reader consults, most specific first. `explicit` is the
387/// caller's own override (a `--subc` flag); `env_named` is the value of
388/// `SUBC_CONNECTION_FILE` read by the caller. Either, when present, is the
389/// ONLY candidate.
390pub 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
402/// Read the reader-side environment and return the first usable connection file.
403/// Unlike writer-side `subc_daemon::bootstrap::connection_file_path()`, this searches
404/// every location where an already-running daemon may have written its file.
405pub 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    // SUBC_CONNECTION_FILE names the daemon the caller means, so it is EXCLUSIVE
425    // rather than first-in-a-list. It used to be pushed ahead of the discovery
426    // candidates, which reads as honouring it and is not: a path that is set and
427    // wrong falls through to discovery and answers from whichever daemon is found
428    // -- in practice production. The reply is then true and about the wrong
429    // machine, and every later verdict inherits that while the operator believes
430    // they are reading a rig.
431    //
432    // A fallback is only a hazard where the primary is optional, so removing the
433    // fallback for a deliberately supplied value removes the class. Returning a
434    // single candidate keeps the existing error path: the file is stat-ed, and an
435    // unreadable one is reported as a failure naming that path.
436    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
499/// The per-user component of the temp-fallback connection-file name.
500///
501/// The daemon writer and every reader use this one implementation so a naming
502/// drift cannot make a running daemon appear absent.
503pub fn user_connection_token() -> String {
504    // On Unix the token is the real uid, read from the kernel. Identity must not
505    // depend on a fallible filesystem probe because a transient failure would
506    // make the same user derive a different connection-file name.
507    #[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    // Any group or other permission bit means the key is exposed beyond the owner.
551    // A file owned by a different user that we can still read implies the same.
552    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    // On Windows the file inherits the per-user profile directory's ACL (owner,
564    // SYSTEM, Administrators only) at create time; see open_owner_only_new. There
565    // are no portable Unix mode bits to re-check on read here.
566    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        // No explicit DACL is set: the connection file is published under the
627        // per-user profile (XDG_RUNTIME_DIR is unset on Windows, so
628        // connection_file_path() falls back to %TEMP% =
629        // %LOCALAPPDATA%\Temp). That directory's inherited ACL already grants
630        // access to only the owning user, SYSTEM, and Administrators — so the
631        // same-host, non-admin attacker (the threat 0600 guards against on the
632        // world-readable Unix /tmp) cannot read the key here. Administrators can
633        // read any file (SeBackup/SeTakeOwnership) on either platform and are
634        // out of scope for a same-host secret. Revisit an explicit owner-only
635        // SECURITY_DESCRIPTOR only if the connection file ever moves off the
636        // per-user profile directory.
637    }
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    /// A GROUP-writable ancestor must refuse, and the passing control on the same
775    /// fixture minus the group bit is what makes this test discriminate.
776    ///
777    /// Both arms are here because the refusal MESSAGE is not the property: a test
778    /// matching on message text passes through a removed check whenever the
779    /// wording survives, which is how the adjacent guard in claustrum kept three
780    /// green tests while examining only `0o002`. The arms differ by one bit on
781    /// one directory and nothing else.
782    #[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    /// `/tmp` is 1777 by design. Without the sticky exemption this check fires on
816    /// every correctly-configured system, and a check that refuses healthy
817    /// configuration gets disabled -- after which it protects nothing.
818    #[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    /// The daemon owns this directory, so the misconfiguration is unproducible
841    /// rather than merely reported when the directory does not yet exist.
842    #[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        // Run the public environment-reading API in a child so this test does not
1033        // mutate process-global environment while sibling tests execute.
1034        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        // A temp stranded by a dead writer: correct shape, old enough to sweep.
1065        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        // A temp from a writer that may still be mid-rename: same shape, fresh.
1076        // Sweeping this would race a concurrent publish.
1077        let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
1078        fs::write(&recent, b"in flight").expect("write recent");
1079
1080        // An old file that is not one of our temps. Age alone must not condemn it.
1081        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        // The raw key byte pattern (0xab) must not appear anywhere in the output.
1115        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, &current).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(&current_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        // Loosen permissions as if the key leaked to other local users.
1225        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}