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};
9
10use serde::{Deserialize, Serialize};
11
12pub const SCHEMA_VERSION: u32 = 1;
13pub const MIN_KEY_LEN: usize = 32;
14pub const KEY_LEN: usize = 32;
15pub const DAEMON_ID_LEN: usize = 16;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Endpoint {
19    pub host: String,
20    pub port: u16,
21}
22
23#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ConnectionInfo {
25    pub schema: u32,
26    pub endpoints: Vec<Endpoint>,
27    pub key: Vec<u8>,
28    pub daemon_id: [u8; DAEMON_ID_LEN],
29    pub pid: u32,
30    pub daemon_ver: String,
31}
32
33// Hand-written so the transport key is never printed. A derived Debug would dump
34// the raw key bytes into any log or panic message that formats a ConnectionInfo.
35impl fmt::Debug for ConnectionInfo {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("ConnectionInfo")
38            .field("schema", &self.schema)
39            .field("endpoints", &self.endpoints)
40            .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
41            .field("daemon_id", &self.daemon_id)
42            .field("pid", &self.pid)
43            .field("daemon_ver", &self.daemon_ver)
44            .finish()
45    }
46}
47
48impl ConnectionInfo {
49    pub fn validate(&self) -> Result<(), ConnectionFileError> {
50        if self.schema != SCHEMA_VERSION {
51            return Err(ConnectionFileError::UnsupportedSchema {
52                schema: self.schema,
53                supported: SCHEMA_VERSION,
54            });
55        }
56        if self.endpoints.is_empty() {
57            return Err(ConnectionFileError::Invalid {
58                reason: "connection file must include at least one endpoint".to_owned(),
59            });
60        }
61        if self.key.len() < MIN_KEY_LEN {
62            return Err(ConnectionFileError::KeyTooShort {
63                len: self.key.len(),
64                min: MIN_KEY_LEN,
65            });
66        }
67        Ok(())
68    }
69}
70
71#[derive(Debug)]
72pub enum ConnectionFileError {
73    MissingParent {
74        path: PathBuf,
75    },
76    MissingFileName {
77        path: PathBuf,
78    },
79    Io {
80        op: &'static str,
81        path: PathBuf,
82        source: io::Error,
83    },
84    JsonRead {
85        path: PathBuf,
86        source: serde_json::Error,
87    },
88    JsonWrite {
89        path: PathBuf,
90        source: serde_json::Error,
91    },
92    Random(getrandom::Error),
93    UnsupportedSchema {
94        schema: u32,
95        supported: u32,
96    },
97    Invalid {
98        reason: String,
99    },
100    KeyTooShort {
101        len: usize,
102        min: usize,
103    },
104    InsecurePermissions {
105        path: PathBuf,
106        mode: u32,
107    },
108}
109
110pub fn write_atomic(
111    path: impl AsRef<Path>,
112    info: &ConnectionInfo,
113) -> Result<(), ConnectionFileError> {
114    let path = path.as_ref();
115    info.validate()?;
116
117    let parent = path
118        .parent()
119        .filter(|parent| !parent.as_os_str().is_empty())
120        .ok_or_else(|| ConnectionFileError::MissingParent {
121            path: path.to_path_buf(),
122        })?;
123    let file_name = path
124        .file_name()
125        .ok_or_else(|| ConnectionFileError::MissingFileName {
126            path: path.to_path_buf(),
127        })?;
128    let temp_path = temp_path(parent, file_name)?;
129    let result = write_atomic_inner(path, &temp_path, info);
130    if result.is_err() {
131        let _ = fs::remove_file(&temp_path);
132    }
133    result
134}
135
136pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
137    let path = path.as_ref();
138    // Refuse to trust a key from a file other local users can read. The key is
139    // published owner-only (0600); if the on-disk file is group/world-accessible
140    // the secret has leaked and the daemon it points at can't be trusted.
141    verify_owner_only(path)?;
142    let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
143        op: "read",
144        path: path.to_path_buf(),
145        source,
146    })?;
147    let info: ConnectionInfo =
148        serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
149            path: path.to_path_buf(),
150            source,
151        })?;
152    info.validate()?;
153    Ok(info)
154}
155
156#[cfg(unix)]
157fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
158    use std::os::unix::fs::PermissionsExt;
159    let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
160        op: "stat",
161        path: path.to_path_buf(),
162        source,
163    })?;
164    let mode = meta.permissions().mode();
165    // Any group or other permission bit means the key is exposed beyond the owner.
166    // A file owned by a different user that we can still read implies the same.
167    if mode & 0o077 != 0 {
168        return Err(ConnectionFileError::InsecurePermissions {
169            path: path.to_path_buf(),
170            mode: mode & 0o777,
171        });
172    }
173    Ok(())
174}
175
176#[cfg(not(unix))]
177fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
178    // On Windows the file inherits the per-user profile directory's ACL (owner,
179    // SYSTEM, Administrators only) at create time; see open_owner_only_new. There
180    // are no portable Unix mode bits to re-check on read here.
181    Ok(())
182}
183
184pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
185    let mut key = vec![0u8; KEY_LEN];
186    getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
187    Ok(key)
188}
189
190pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
191    let mut daemon_id = [0u8; DAEMON_ID_LEN];
192    getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
193    Ok(daemon_id)
194}
195
196fn write_atomic_inner(
197    path: &Path,
198    temp_path: &Path,
199    info: &ConnectionInfo,
200) -> Result<(), ConnectionFileError> {
201    let json =
202        serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
203            path: path.to_path_buf(),
204            source,
205        })?;
206
207    {
208        let mut file =
209            open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
210                op: "create_temp",
211                path: temp_path.to_path_buf(),
212                source,
213            })?;
214        file.write_all(&json)
215            .and_then(|()| file.sync_all())
216            .map_err(|source| ConnectionFileError::Io {
217                op: "write_temp",
218                path: temp_path.to_path_buf(),
219                source,
220            })?;
221    }
222
223    fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
224        op: "rename",
225        path: path.to_path_buf(),
226        source,
227    })?;
228    Ok(())
229}
230
231fn open_owner_only_new(path: &Path) -> io::Result<File> {
232    let mut options = OpenOptions::new();
233    options.write(true).create_new(true);
234    #[cfg(unix)]
235    {
236        use std::os::unix::fs::OpenOptionsExt;
237        options.mode(0o600);
238    }
239    #[cfg(windows)]
240    {
241        // No explicit DACL is set: the connection file is published under the
242        // per-user profile (XDG_RUNTIME_DIR is unset on Windows, so
243        // connection_file_path() falls back to %TEMP% =
244        // %LOCALAPPDATA%\Temp). That directory's inherited ACL already grants
245        // access to only the owning user, SYSTEM, and Administrators — so the
246        // same-host, non-admin attacker (the threat 0600 guards against on the
247        // world-readable Unix /tmp) cannot read the key here. Administrators can
248        // read any file (SeBackup/SeTakeOwnership) on either platform and are
249        // out of scope for a same-host secret. Revisit an explicit owner-only
250        // SECURITY_DESCRIPTOR only if the connection file ever moves off the
251        // per-user profile directory.
252    }
253    options.open(path)
254}
255
256fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
257    let mut suffix = [0u8; 16];
258    getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
259    let file_name = file_name.to_string_lossy();
260    Ok(parent.join(format!(
261        ".{file_name}.{}.{}.tmp",
262        process::id(),
263        hex(&suffix)
264    )))
265}
266
267fn hex(bytes: &[u8]) -> String {
268    const HEX: &[u8; 16] = b"0123456789abcdef";
269    let mut out = String::with_capacity(bytes.len() * 2);
270    for byte in bytes {
271        out.push(HEX[(byte >> 4) as usize] as char);
272        out.push(HEX[(byte & 0x0f) as usize] as char);
273    }
274    out
275}
276
277impl fmt::Display for ConnectionFileError {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        match self {
280            Self::MissingParent { path } => {
281                write!(f, "connection file path has no parent: {}", path.display())
282            }
283            Self::MissingFileName { path } => {
284                write!(
285                    f,
286                    "connection file path has no file name: {}",
287                    path.display()
288                )
289            }
290            Self::Io { op, path, source } => write!(
291                f,
292                "connection file {op} failed for {}: {source}",
293                path.display()
294            ),
295            Self::JsonRead { path, source } => write!(
296                f,
297                "connection file JSON read failed for {}: {source}",
298                path.display()
299            ),
300            Self::JsonWrite { path, source } => write!(
301                f,
302                "connection file JSON write failed for {}: {source}",
303                path.display()
304            ),
305            Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
306            Self::UnsupportedSchema { schema, supported } => write!(
307                f,
308                "unsupported connection file schema {schema}; expected {supported}"
309            ),
310            Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
311            Self::KeyTooShort { len, min } => write!(
312                f,
313                "connection file key is too short: {len} bytes, need at least {min}"
314            ),
315            Self::InsecurePermissions { path, mode } => write!(
316                f,
317                "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
318                path.display()
319            ),
320        }
321    }
322}
323
324impl Error for ConnectionFileError {
325    fn source(&self) -> Option<&(dyn Error + 'static)> {
326        match self {
327            Self::Io { source, .. } => Some(source),
328            Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
329            Self::Random(_) => None,
330            Self::MissingParent { .. }
331            | Self::MissingFileName { .. }
332            | Self::UnsupportedSchema { .. }
333            | Self::Invalid { .. }
334            | Self::KeyTooShort { .. }
335            | Self::InsecurePermissions { .. } => None,
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn sample_info() -> ConnectionInfo {
345        ConnectionInfo {
346            schema: SCHEMA_VERSION,
347            endpoints: vec![Endpoint {
348                host: "127.0.0.1".to_owned(),
349                port: 8799,
350            }],
351            key: vec![0xABu8; KEY_LEN],
352            daemon_id: [0x11u8; DAEMON_ID_LEN],
353            pid: 4242,
354            daemon_ver: "subc-test".to_owned(),
355        }
356    }
357
358    fn unique_temp_path() -> PathBuf {
359        let mut suffix = [0u8; 8];
360        getrandom::getrandom(&mut suffix).expect("random suffix");
361        let mut name = String::from("subc-connfile-test-");
362        for byte in suffix {
363            name.push_str(&format!("{byte:02x}"));
364        }
365        name.push_str(".json");
366        std::env::temp_dir().join(name)
367    }
368
369    #[test]
370    fn debug_redacts_key_bytes() {
371        let info = sample_info();
372        let rendered = format!("{info:?}");
373        assert!(
374            rendered.contains("redacted"),
375            "Debug must mark the key as redacted: {rendered}"
376        );
377        // The raw key byte pattern (0xab) must not appear anywhere in the output.
378        assert!(
379            !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
380            "Debug must not leak raw key bytes: {rendered}"
381        );
382    }
383
384    #[test]
385    fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
386        let mut unsupported_schema = sample_info();
387        unsupported_schema.schema = SCHEMA_VERSION + 1;
388        let before = unsupported_schema.clone();
389        let err = unsupported_schema
390            .validate()
391            .expect_err("unsupported schema must be rejected");
392        assert!(matches!(
393            err,
394            ConnectionFileError::UnsupportedSchema {
395                schema,
396                supported: SCHEMA_VERSION,
397            } if schema == SCHEMA_VERSION + 1
398        ));
399        assert_eq!(unsupported_schema, before, "validate must not mutate input");
400
401        let mut empty_endpoints = sample_info();
402        empty_endpoints.endpoints.clear();
403        let before = empty_endpoints.clone();
404        let err = empty_endpoints
405            .validate()
406            .expect_err("empty endpoint list must be rejected");
407        assert!(matches!(
408            err,
409            ConnectionFileError::Invalid { ref reason }
410                if reason == "connection file must include at least one endpoint"
411        ));
412        assert_eq!(empty_endpoints, before, "validate must not mutate input");
413
414        let mut short_key = sample_info();
415        short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
416        let before = short_key.clone();
417        let err = short_key
418            .validate()
419            .expect_err("short key must be rejected");
420        assert!(matches!(
421            err,
422            ConnectionFileError::KeyTooShort {
423                len,
424                min: MIN_KEY_LEN,
425            } if len == MIN_KEY_LEN - 1
426        ));
427        assert_eq!(short_key, before, "validate must not mutate input");
428    }
429
430    #[test]
431    fn read_accepts_owner_only_file() {
432        let path = unique_temp_path();
433        write_atomic(&path, &sample_info()).expect("write owner-only file");
434        let read_back = read(&path).expect("owner-only file is readable");
435        assert_eq!(read_back, sample_info());
436        let _ = fs::remove_file(&path);
437    }
438
439    #[cfg(unix)]
440    #[test]
441    fn read_rejects_group_or_world_readable_file() {
442        use std::os::unix::fs::PermissionsExt;
443
444        let path = unique_temp_path();
445        write_atomic(&path, &sample_info()).expect("write owner-only file");
446        // Loosen permissions as if the key leaked to other local users.
447        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
448
449        let err = read(&path).expect_err("group/world-readable key file must be rejected");
450        assert!(
451            matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
452            "expected InsecurePermissions, got {err:?}"
453        );
454        let _ = fs::remove_file(&path);
455    }
456}