use geth_types::{PipeId, ResourceId, UnixMillis}; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::path::{Component, Path, PathBuf}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PipeResource { pub id: PipeId, pub resource: ResourceId, pub name: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PipeListener { pub id: PipeId, pub name: String, pub listened_at: UnixMillis, pub note: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PipeConnection { pub target: String, pub connected_at: UnixMillis, pub local_listener_found: bool, pub note: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PipeMessage { pub pipe: String, pub data_base64: String, pub received_at: UnixMillis, pub source_node: Option, pub note: String, } #[derive(Debug, thiserror::Error)] pub enum PipeError { #[error("invalid pipe name or target: {0}")] InvalidName(String), #[error("invalid TCP address: {0}")] InvalidTcpAddress(String), #[error("TCP forwarding addresses must be loopback addresses: {0}")] NonLoopbackTcpAddress(String), #[error("invalid Unix socket path: {0}")] InvalidUnixSocketPath(String), } pub fn validate_pipe_name(name: &str) -> Result<(), PipeError> { if name.is_empty() || !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':')) { return Err(PipeError::InvalidName(name.to_owned())); } Ok(()) } pub fn validate_tcp_forward_listen_addr(addr: &str) -> Result { let addr = addr .parse::() .map_err(|_| PipeError::InvalidTcpAddress(addr.to_owned()))?; if !addr.ip().is_loopback() { return Err(PipeError::NonLoopbackTcpAddress(addr.to_string())); } Ok(addr) } pub fn validate_tcp_forward_target_addr(addr: &str) -> Result { let addr = addr .parse::() .map_err(|_| PipeError::InvalidTcpAddress(addr.to_owned()))?; if !addr.ip().is_loopback() { return Err(PipeError::NonLoopbackTcpAddress(addr.to_string())); } Ok(addr) } pub fn validate_unix_forward_path(path: &Path) -> Result { if path.as_os_str().is_empty() || !path.is_absolute() { return Err(PipeError::InvalidUnixSocketPath(path.display().to_string())); } if path .components() .any(|component| matches!(component, Component::ParentDir)) { return Err(PipeError::InvalidUnixSocketPath(path.display().to_string())); } Ok(path.to_path_buf()) } #[must_use] pub fn pipe_roadmap() -> &'static str { "future pipes are authorized Iroh bidirectional streams for stdin/stdout and forwarding" } #[must_use] pub fn local_pipe_runtime_note() -> &'static str { "daemon-lifetime pipe runtime; byte messages are buffered locally and remote writes use the Iroh pipe ALPN" } #[cfg(test)] mod tests { use super::*; #[test] fn pipe_name_validation_rejects_paths_and_empty_names() { assert!(validate_pipe_name("inbox").is_ok()); assert!(validate_pipe_name("node:laptop").is_ok()); assert!(validate_pipe_name("").is_err()); assert!(validate_pipe_name("../inbox").is_err()); assert!(validate_pipe_name("inbox/main").is_err()); assert!(validate_pipe_name("inbox main").is_err()); } #[test] fn tcp_forward_addresses_must_be_explicit_loopback_socket_addrs() { assert!(validate_tcp_forward_listen_addr("127.0.0.1:9000").is_ok()); assert!(validate_tcp_forward_target_addr("[::1]:22").is_ok()); assert!(validate_tcp_forward_listen_addr("localhost:9000").is_err()); assert!(validate_tcp_forward_target_addr("0.0.0.0:22").is_err()); assert!(validate_tcp_forward_target_addr("192.0.2.10:22").is_err()); } #[test] fn unix_forward_paths_must_be_absolute_without_parent_components() { assert!(validate_unix_forward_path(Path::new("/tmp/geth.sock")).is_ok()); assert!(validate_unix_forward_path(Path::new("relative.sock")).is_err()); assert!(validate_unix_forward_path(Path::new("/tmp/../geth.sock")).is_err()); } }