Skip to main content

subc_transport/
connection_file.rs

1use std::{
2    error::Error,
3    fmt,
4    fs::{self, File, OpenOptions},
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process,
8    time::{Duration, SystemTime},
9};
10
11use serde::{Deserialize, Serialize};
12use subc_protocol::PROTOCOL_VERSION;
13
14pub const SCHEMA_VERSION: u32 = 1;
15pub const MIN_KEY_LEN: usize = 32;
16pub const KEY_LEN: usize = 32;
17pub const DAEMON_ID_LEN: usize = 16;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Endpoint {
21    pub host: String,
22    pub port: u16,
23}
24
25#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ConnectionInfo {
27    pub schema: u32,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub wire_version: Option<u8>,
30    pub endpoints: Vec<Endpoint>,
31    pub key: Vec<u8>,
32    pub daemon_id: [u8; DAEMON_ID_LEN],
33    pub pid: u32,
34    pub daemon_ver: String,
35}
36
37// Hand-written so the transport key is never printed. A derived Debug would dump
38// the raw key bytes into any log or panic message that formats a ConnectionInfo.
39impl fmt::Debug for ConnectionInfo {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("ConnectionInfo")
42            .field("schema", &self.schema)
43            .field("wire_version", &self.wire_version)
44            .field("endpoints", &self.endpoints)
45            .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
46            .field("daemon_id", &self.daemon_id)
47            .field("pid", &self.pid)
48            .field("daemon_ver", &self.daemon_ver)
49            .finish()
50    }
51}
52
53impl ConnectionInfo {
54    pub fn validate(&self) -> Result<(), ConnectionFileError> {
55        if self.schema != SCHEMA_VERSION {
56            return Err(ConnectionFileError::UnsupportedSchema {
57                schema: self.schema,
58                supported: SCHEMA_VERSION,
59            });
60        }
61        if self.endpoints.is_empty() {
62            return Err(ConnectionFileError::Invalid {
63                reason: "connection file must include at least one endpoint".to_owned(),
64            });
65        }
66        if self.key.len() < MIN_KEY_LEN {
67            return Err(ConnectionFileError::KeyTooShort {
68                len: self.key.len(),
69                min: MIN_KEY_LEN,
70            });
71        }
72        Ok(())
73    }
74
75    /// Validates a declared envelope version without rejecting older files that
76    /// omit the additive field.
77    pub fn validate_wire_version(&self, supported: u8) -> Result<(), ConnectionFileError> {
78        if let Some(file) = self.wire_version {
79            if file != supported {
80                return Err(ConnectionFileError::WireVersionMismatch { file, supported });
81            }
82        }
83        Ok(())
84    }
85}
86
87#[derive(Debug)]
88pub enum ConnectionFileError {
89    MissingParent {
90        path: PathBuf,
91    },
92    MissingFileName {
93        path: PathBuf,
94    },
95    Io {
96        op: &'static str,
97        path: PathBuf,
98        source: io::Error,
99    },
100    JsonRead {
101        path: PathBuf,
102        source: serde_json::Error,
103    },
104    JsonWrite {
105        path: PathBuf,
106        source: serde_json::Error,
107    },
108    Random(getrandom::Error),
109    UnsupportedSchema {
110        schema: u32,
111        supported: u32,
112    },
113    WireVersionMismatch {
114        file: u8,
115        supported: u8,
116    },
117    Invalid {
118        reason: String,
119    },
120    KeyTooShort {
121        len: usize,
122        min: usize,
123    },
124    InsecurePermissions {
125        path: PathBuf,
126        mode: u32,
127    },
128}
129
130pub fn write_atomic(
131    path: impl AsRef<Path>,
132    info: &ConnectionInfo,
133) -> Result<(), ConnectionFileError> {
134    let path = path.as_ref();
135    info.validate()?;
136
137    let parent = path
138        .parent()
139        .filter(|parent| !parent.as_os_str().is_empty())
140        .ok_or_else(|| ConnectionFileError::MissingParent {
141            path: path.to_path_buf(),
142        })?;
143    let file_name = path
144        .file_name()
145        .ok_or_else(|| ConnectionFileError::MissingFileName {
146            path: path.to_path_buf(),
147        })?;
148    // Sweep temps stranded by an earlier writer before creating our own. The
149    // error path below removes this call's temp, but nothing removes one left by
150    // a process that died BETWEEN create and rename -- and a connection file
151    // carries the daemon's auth key, so a stranded temp is a stale credential
152    // sitting in the runtime directory indefinitely. Owner-only mode means no
153    // other user can read it and the key dies with the daemon that minted it;
154    // the objection is to key material with no owner and no expiry, not to an
155    // active leak.
156    //
157    // Best-effort and non-fatal: publishing must not fail because cleanup could
158    // not remove somebody else's file.
159    sweep_stale_temps(parent, file_name);
160
161    let temp_path = temp_path(parent, file_name)?;
162    let result = write_atomic_inner(path, &temp_path, info);
163    if result.is_err() {
164        let _ = fs::remove_file(&temp_path);
165    }
166    result
167}
168
169/// Remove `.<file_name>.<pid>.<hex>.tmp` siblings older than ten minutes.
170///
171/// AGE IS THE SOLE PREDICATE. Testing whether the embedded pid is alive reads
172/// false-positive on exactly the oldest files, because pid numbers are recycled:
173/// an unrelated long-lived process inherits the number and the stalest temp
174/// looks owned. That failure direction resembles caution, which is why nobody
175/// investigates the survivors. Ten minutes is far longer than the window this
176/// guards, which spans two syscalls.
177fn sweep_stale_temps(parent: &Path, file_name: &std::ffi::OsStr) {
178    const STALE_AFTER: Duration = Duration::from_secs(600);
179
180    let prefix = format!(".{}.", file_name.to_string_lossy());
181    let Ok(entries) = fs::read_dir(parent) else {
182        return;
183    };
184    for entry in entries.flatten() {
185        let name = entry.file_name();
186        let name = name.to_string_lossy();
187        if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
188            continue;
189        }
190        let stale = entry
191            .metadata()
192            .and_then(|meta| meta.modified())
193            .map(|modified| {
194                SystemTime::now()
195                    .duration_since(modified)
196                    .is_ok_and(|age| age >= STALE_AFTER)
197            })
198            .unwrap_or(false);
199        if stale {
200            let _ = fs::remove_file(entry.path());
201        }
202    }
203}
204
205pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
206    let path = path.as_ref();
207    // Refuse to trust a key from a file other local users can read. The key is
208    // published owner-only (0600); if the on-disk file is group/world-accessible
209    // the secret has leaked and the daemon it points at can't be trusted.
210    verify_owner_only(path)?;
211    let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
212        op: "read",
213        path: path.to_path_buf(),
214        source,
215    })?;
216    let info: ConnectionInfo =
217        serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
218            path: path.to_path_buf(),
219            source,
220        })?;
221    info.validate()?;
222    Ok(info)
223}
224
225/// Reads connection information for a client and rejects a declared envelope
226/// version this binary cannot decode before a TCP connection is attempted.
227pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
228    let info = read(path)?;
229    info.validate_wire_version(PROTOCOL_VERSION)?;
230    Ok(info)
231}
232
233#[cfg(unix)]
234fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
235    use std::os::unix::fs::PermissionsExt;
236    let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
237        op: "stat",
238        path: path.to_path_buf(),
239        source,
240    })?;
241    let mode = meta.permissions().mode();
242    // Any group or other permission bit means the key is exposed beyond the owner.
243    // A file owned by a different user that we can still read implies the same.
244    if mode & 0o077 != 0 {
245        return Err(ConnectionFileError::InsecurePermissions {
246            path: path.to_path_buf(),
247            mode: mode & 0o777,
248        });
249    }
250    Ok(())
251}
252
253#[cfg(not(unix))]
254fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
255    // On Windows the file inherits the per-user profile directory's ACL (owner,
256    // SYSTEM, Administrators only) at create time; see open_owner_only_new. There
257    // are no portable Unix mode bits to re-check on read here.
258    Ok(())
259}
260
261pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
262    let mut key = vec![0u8; KEY_LEN];
263    getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
264    Ok(key)
265}
266
267pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
268    let mut daemon_id = [0u8; DAEMON_ID_LEN];
269    getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
270    Ok(daemon_id)
271}
272
273fn write_atomic_inner(
274    path: &Path,
275    temp_path: &Path,
276    info: &ConnectionInfo,
277) -> Result<(), ConnectionFileError> {
278    let json =
279        serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
280            path: path.to_path_buf(),
281            source,
282        })?;
283
284    {
285        let mut file =
286            open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
287                op: "create_temp",
288                path: temp_path.to_path_buf(),
289                source,
290            })?;
291        file.write_all(&json)
292            .and_then(|()| file.sync_all())
293            .map_err(|source| ConnectionFileError::Io {
294                op: "write_temp",
295                path: temp_path.to_path_buf(),
296                source,
297            })?;
298    }
299
300    fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
301        op: "rename",
302        path: path.to_path_buf(),
303        source,
304    })?;
305    Ok(())
306}
307
308fn open_owner_only_new(path: &Path) -> io::Result<File> {
309    let mut options = OpenOptions::new();
310    options.write(true).create_new(true);
311    #[cfg(unix)]
312    {
313        use std::os::unix::fs::OpenOptionsExt;
314        options.mode(0o600);
315    }
316    #[cfg(windows)]
317    {
318        // No explicit DACL is set: the connection file is published under the
319        // per-user profile (XDG_RUNTIME_DIR is unset on Windows, so
320        // connection_file_path() falls back to %TEMP% =
321        // %LOCALAPPDATA%\Temp). That directory's inherited ACL already grants
322        // access to only the owning user, SYSTEM, and Administrators — so the
323        // same-host, non-admin attacker (the threat 0600 guards against on the
324        // world-readable Unix /tmp) cannot read the key here. Administrators can
325        // read any file (SeBackup/SeTakeOwnership) on either platform and are
326        // out of scope for a same-host secret. Revisit an explicit owner-only
327        // SECURITY_DESCRIPTOR only if the connection file ever moves off the
328        // per-user profile directory.
329    }
330    options.open(path)
331}
332
333fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
334    let mut suffix = [0u8; 16];
335    getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
336    let file_name = file_name.to_string_lossy();
337    Ok(parent.join(format!(
338        ".{file_name}.{}.{}.tmp",
339        process::id(),
340        hex(&suffix)
341    )))
342}
343
344fn hex(bytes: &[u8]) -> String {
345    const HEX: &[u8; 16] = b"0123456789abcdef";
346    let mut out = String::with_capacity(bytes.len() * 2);
347    for byte in bytes {
348        out.push(HEX[(byte >> 4) as usize] as char);
349        out.push(HEX[(byte & 0x0f) as usize] as char);
350    }
351    out
352}
353
354impl fmt::Display for ConnectionFileError {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        match self {
357            Self::MissingParent { path } => {
358                write!(f, "connection file path has no parent: {}", path.display())
359            }
360            Self::MissingFileName { path } => {
361                write!(
362                    f,
363                    "connection file path has no file name: {}",
364                    path.display()
365                )
366            }
367            Self::Io { op, path, source } => write!(
368                f,
369                "connection file {op} failed for {}: {source}",
370                path.display()
371            ),
372            Self::JsonRead { path, source } => write!(
373                f,
374                "connection file JSON read failed for {}: {source}",
375                path.display()
376            ),
377            Self::JsonWrite { path, source } => write!(
378                f,
379                "connection file JSON write failed for {}: {source}",
380                path.display()
381            ),
382            Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
383            Self::UnsupportedSchema { schema, supported } => write!(
384                f,
385                "unsupported connection file schema {schema}; expected {supported}"
386            ),
387            Self::WireVersionMismatch { file, supported } => write!(
388                f,
389                "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
390            ),
391            Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
392            Self::KeyTooShort { len, min } => write!(
393                f,
394                "connection file key is too short: {len} bytes, need at least {min}"
395            ),
396            Self::InsecurePermissions { path, mode } => write!(
397                f,
398                "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
399                path.display()
400            ),
401        }
402    }
403}
404
405impl Error for ConnectionFileError {
406    fn source(&self) -> Option<&(dyn Error + 'static)> {
407        match self {
408            Self::Io { source, .. } => Some(source),
409            Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
410            Self::Random(_) => None,
411            Self::MissingParent { .. }
412            | Self::MissingFileName { .. }
413            | Self::UnsupportedSchema { .. }
414            | Self::WireVersionMismatch { .. }
415            | Self::Invalid { .. }
416            | Self::KeyTooShort { .. }
417            | Self::InsecurePermissions { .. } => None,
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    fn sample_info() -> ConnectionInfo {
427        ConnectionInfo {
428            schema: SCHEMA_VERSION,
429            wire_version: None,
430            endpoints: vec![Endpoint {
431                host: "127.0.0.1".to_owned(),
432                port: 8799,
433            }],
434            key: vec![0xABu8; KEY_LEN],
435            daemon_id: [0x11u8; DAEMON_ID_LEN],
436            pid: 4242,
437            daemon_ver: "subc-test".to_owned(),
438        }
439    }
440
441    fn unique_temp_path() -> PathBuf {
442        let mut suffix = [0u8; 8];
443        getrandom::getrandom(&mut suffix).expect("random suffix");
444        let mut name = String::from("subc-connfile-test-");
445        for byte in suffix {
446            name.push_str(&format!("{byte:02x}"));
447        }
448        name.push_str(".json");
449        std::env::temp_dir().join(name)
450    }
451
452    #[test]
453    fn write_atomic_sweeps_stale_temps_and_spares_recent_and_unrelated_files() {
454        let dir = std::env::temp_dir().join(format!("subc-sweep-{}", process::id()));
455        fs::create_dir_all(&dir).expect("create dir");
456        let target = dir.join("subc-connection.json");
457
458        // A temp stranded by a dead writer: correct shape, old enough to sweep.
459        let stale = dir.join(".subc-connection.json.99999.deadbeef.tmp");
460        fs::write(&stale, b"stranded").expect("write stale");
461        let old = SystemTime::now() - Duration::from_secs(3600);
462        File::options()
463            .write(true)
464            .open(&stale)
465            .expect("open stale")
466            .set_modified(old)
467            .expect("backdate stale");
468
469        // A temp from a writer that may still be mid-rename: same shape, fresh.
470        // Sweeping this would race a concurrent publish.
471        let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
472        fs::write(&recent, b"in flight").expect("write recent");
473
474        // An old file that is not one of our temps. Age alone must not condemn it.
475        let unrelated = dir.join("unrelated.txt");
476        fs::write(&unrelated, b"not ours").expect("write unrelated");
477        File::options()
478            .write(true)
479            .open(&unrelated)
480            .expect("open unrelated")
481            .set_modified(old)
482            .expect("backdate unrelated");
483
484        write_atomic(&target, &sample_info()).expect("publish");
485
486        assert!(!stale.exists(), "a stale temp must be swept");
487        assert!(
488            recent.exists(),
489            "a recent temp may belong to an in-flight publish and must be spared"
490        );
491        assert!(
492            unrelated.exists(),
493            "age alone must not condemn a file that is not one of our temps"
494        );
495        assert!(target.exists(), "the publish itself must still land");
496
497        let _ = fs::remove_dir_all(&dir);
498    }
499
500    #[test]
501    fn debug_redacts_key_bytes() {
502        let info = sample_info();
503        let rendered = format!("{info:?}");
504        assert!(
505            rendered.contains("redacted"),
506            "Debug must mark the key as redacted: {rendered}"
507        );
508        // The raw key byte pattern (0xab) must not appear anywhere in the output.
509        assert!(
510            !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
511            "Debug must not leak raw key bytes: {rendered}"
512        );
513    }
514
515    #[test]
516    fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
517        let mut unsupported_schema = sample_info();
518        unsupported_schema.schema = SCHEMA_VERSION + 1;
519        let before = unsupported_schema.clone();
520        let err = unsupported_schema
521            .validate()
522            .expect_err("unsupported schema must be rejected");
523        assert!(matches!(
524            err,
525            ConnectionFileError::UnsupportedSchema {
526                schema,
527                supported: SCHEMA_VERSION,
528            } if schema == SCHEMA_VERSION + 1
529        ));
530        assert_eq!(unsupported_schema, before, "validate must not mutate input");
531
532        let mut empty_endpoints = sample_info();
533        empty_endpoints.endpoints.clear();
534        let before = empty_endpoints.clone();
535        let err = empty_endpoints
536            .validate()
537            .expect_err("empty endpoint list must be rejected");
538        assert!(matches!(
539            err,
540            ConnectionFileError::Invalid { ref reason }
541                if reason == "connection file must include at least one endpoint"
542        ));
543        assert_eq!(empty_endpoints, before, "validate must not mutate input");
544
545        let mut short_key = sample_info();
546        short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
547        let before = short_key.clone();
548        let err = short_key
549            .validate()
550            .expect_err("short key must be rejected");
551        assert!(matches!(
552            err,
553            ConnectionFileError::KeyTooShort {
554                len,
555                min: MIN_KEY_LEN,
556            } if len == MIN_KEY_LEN - 1
557        ));
558        assert_eq!(short_key, before, "validate must not mutate input");
559    }
560
561    #[test]
562    fn optional_wire_version_round_trips() {
563        let path = unique_temp_path();
564        let legacy = sample_info();
565        write_atomic(&path, &legacy).expect("write legacy connection file");
566        let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
567        assert!(!legacy_json.contains("wire_version"));
568        assert_eq!(
569            read_for_client(&path).expect("legacy file remains readable"),
570            legacy
571        );
572
573        let mut current = sample_info();
574        current.wire_version = Some(PROTOCOL_VERSION);
575        write_atomic(&path, &current).expect("write current connection file");
576        let current_json = fs::read_to_string(&path).expect("read current connection file");
577        let current_json: serde_json::Value =
578            serde_json::from_str(&current_json).expect("parse current connection file");
579        assert_eq!(
580            current_json["wire_version"].as_u64(),
581            Some(u64::from(PROTOCOL_VERSION))
582        );
583        assert_eq!(
584            read_for_client(&path).expect("current file is readable"),
585            current
586        );
587        let _ = fs::remove_file(&path);
588    }
589
590    #[test]
591    fn read_for_client_rejects_mismatched_wire_version() {
592        let path = unique_temp_path();
593        let mut info = sample_info();
594        let file_version = PROTOCOL_VERSION + 1;
595        info.wire_version = Some(file_version);
596        write_atomic(&path, &info).expect("write mismatched connection file");
597
598        let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
599        assert!(matches!(
600            err,
601            ConnectionFileError::WireVersionMismatch { file, supported }
602                if file == file_version && supported == PROTOCOL_VERSION
603        ));
604        let rendered = err.to_string();
605        assert!(rendered.contains(&file_version.to_string()));
606        assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
607        assert!(rendered.contains("binary must be upgraded"));
608        let _ = fs::remove_file(&path);
609    }
610
611    #[cfg(unix)]
612    #[test]
613    fn read_rejects_group_or_world_readable_file() {
614        use std::os::unix::fs::PermissionsExt;
615
616        let path = unique_temp_path();
617        write_atomic(&path, &sample_info()).expect("write owner-only file");
618        // Loosen permissions as if the key leaked to other local users.
619        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
620
621        let err = read(&path).expect_err("group/world-readable key file must be rejected");
622        assert!(
623            matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
624            "expected InsecurePermissions, got {err:?}"
625        );
626        let _ = fs::remove_file(&path);
627    }
628}