Skip to main content

cccc_sdk/
endpoint.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7use crate::{Error, Result};
8
9/// Transport endpoint advertised by the CCCC daemon.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum DaemonEndpoint {
12    Unix(PathBuf),
13    Tcp { host: String, port: u16 },
14}
15
16#[derive(Debug, Deserialize)]
17struct EndpointDescriptor {
18    v: u32,
19    transport: String,
20    #[serde(default)]
21    path: String,
22    #[serde(default)]
23    host: String,
24    #[serde(default)]
25    port: u16,
26}
27
28/// Discover a daemon endpoint under `cccc_home`, `CCCC_HOME`, or `~/.cccc`.
29pub fn discover_endpoint(cccc_home: Option<&Path>) -> Result<DaemonEndpoint> {
30    let home = match cccc_home {
31        Some(path) => path.to_path_buf(),
32        None => default_cccc_home()?,
33    };
34    let descriptor_path = home.join("daemon").join("ccccd.addr.json");
35
36    if let Ok(contents) = fs::read_to_string(&descriptor_path) {
37        let descriptor: EndpointDescriptor = serde_json::from_str(&contents)
38            .map_err(|error| Error::InvalidEndpoint(error.to_string()))?;
39        return endpoint_from_descriptor(descriptor);
40    }
41
42    #[cfg(unix)]
43    {
44        let socket = home.join("daemon").join("ccccd.sock");
45        if socket.exists() {
46            return Ok(DaemonEndpoint::Unix(socket));
47        }
48    }
49
50    Err(Error::EndpointNotFound(
51        descriptor_path.display().to_string(),
52    ))
53}
54
55fn default_cccc_home() -> Result<PathBuf> {
56    if let Some(path) = env::var_os("CCCC_HOME").filter(|value| !value.is_empty()) {
57        return Ok(PathBuf::from(path));
58    }
59    let user_home = env::var_os("HOME")
60        .or_else(|| env::var_os("USERPROFILE"))
61        .ok_or_else(|| Error::EndpointNotFound("HOME/USERPROFILE is not set".into()))?;
62    Ok(PathBuf::from(user_home).join(".cccc"))
63}
64
65fn endpoint_from_descriptor(descriptor: EndpointDescriptor) -> Result<DaemonEndpoint> {
66    if descriptor.v != 1 {
67        return Err(Error::InvalidEndpoint(format!(
68            "descriptor version must be 1, got {}",
69            descriptor.v
70        )));
71    }
72    match descriptor.transport.as_str() {
73        "unix" if !descriptor.path.is_empty() => {
74            Ok(DaemonEndpoint::Unix(PathBuf::from(descriptor.path)))
75        }
76        "tcp" if descriptor.port > 0 => Ok(DaemonEndpoint::Tcp {
77            host: normalize_tcp_host(&descriptor.host),
78            port: descriptor.port,
79        }),
80        transport => Err(Error::InvalidEndpoint(format!(
81            "invalid transport or address: {transport}"
82        ))),
83    }
84}
85
86fn normalize_tcp_host(host: &str) -> String {
87    let value = host.trim();
88    let value = value
89        .strip_prefix('[')
90        .and_then(|value| value.strip_suffix(']'))
91        .unwrap_or(value)
92        .trim();
93    match value {
94        "" | "0.0.0.0" | "localhost" => "127.0.0.1".into(),
95        "::" => "::1".into(),
96        value => value.into(),
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::time::{SystemTime, UNIX_EPOCH};
104
105    fn temp_home() -> PathBuf {
106        let nonce = SystemTime::now()
107            .duration_since(UNIX_EPOCH)
108            .expect("clock")
109            .as_nanos();
110        env::temp_dir().join(format!("cccc-sdk-endpoint-{}-{nonce}", std::process::id()))
111    }
112
113    #[test]
114    fn discovers_and_normalizes_tcp_descriptor() {
115        let home = temp_home();
116        let daemon = home.join("daemon");
117        fs::create_dir_all(&daemon).expect("create daemon dir");
118        fs::write(
119            daemon.join("ccccd.addr.json"),
120            r#"{"v":1,"transport":"tcp","host":"0.0.0.0","port":43123}"#,
121        )
122        .expect("write descriptor");
123
124        let endpoint = discover_endpoint(Some(&home)).expect("discover endpoint");
125        assert_eq!(
126            endpoint,
127            DaemonEndpoint::Tcp {
128                host: "127.0.0.1".into(),
129                port: 43123,
130            }
131        );
132        fs::remove_dir_all(home).expect("cleanup");
133    }
134
135    #[test]
136    fn preserves_connectable_ipv6_and_normalizes_only_the_wildcard() {
137        assert_eq!(normalize_tcp_host("::1"), "::1");
138        assert_eq!(normalize_tcp_host("2001:db8::1"), "2001:db8::1");
139        assert_eq!(normalize_tcp_host("[::1]"), "::1");
140        assert_eq!(normalize_tcp_host("::"), "::1");
141        assert_eq!(normalize_tcp_host("[::]"), "::1");
142    }
143
144    #[test]
145    fn rejects_unknown_descriptor_versions() {
146        let result = endpoint_from_descriptor(EndpointDescriptor {
147            v: 2,
148            transport: "tcp".into(),
149            path: String::new(),
150            host: "127.0.0.1".into(),
151            port: 43123,
152        });
153        assert!(matches!(result, Err(Error::InvalidEndpoint(_))));
154    }
155}