use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const MAX_REQUEST_FRAME: usize = 16 * 1024 * 1024;
pub const MAX_REPLY_FRAME: usize = 256 * 1024 * 1024;
pub const MAX_TOKEN_LINE: u64 = 512;
pub const ENDPOINT_FILE_VERSION: u32 = 1;
pub fn write_frame(w: &mut impl Write, bytes: &[u8], max: usize) -> io::Result<()> {
if bytes.len() > max {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"frame of {} bytes exceeds the {max}-byte limit",
bytes.len()
),
));
}
w.write_all(&(bytes.len() as u32).to_le_bytes())?;
w.write_all(bytes)?;
w.flush()
}
pub fn read_frame(r: &mut impl Read, max: usize) -> io::Result<Vec<u8>> {
let mut len = [0u8; 4];
r.read_exact(&mut len)?;
let frame_len = u32::from_le_bytes(len) as usize;
if frame_len > max {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("frame of {frame_len} bytes exceeds the {max}-byte limit"),
));
}
let mut buf = vec![0u8; frame_len];
r.read_exact(&mut buf)?;
Ok(buf)
}
pub fn write_token(w: &mut impl Write, token: &str) -> io::Result<()> {
w.write_all(token.as_bytes())?;
w.write_all(b"\n")?;
w.flush()
}
pub fn read_token(r: &mut impl Read) -> io::Result<String> {
read_token_by(r, None)
}
pub fn read_token_by(
r: &mut impl Read,
deadline: Option<std::time::Instant>,
) -> io::Result<String> {
let mut line = Vec::with_capacity(64);
let mut byte = [0u8; 1];
while (line.len() as u64) < MAX_TOKEN_LINE {
if deadline.is_some_and(|d| std::time::Instant::now() >= d) {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"the token handshake did not complete within its deadline",
));
}
match r.read(&mut byte) {
Ok(0) => break, Ok(_) if byte[0] == b'\n' => break,
Ok(_) => line.push(byte[0]),
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(String::from_utf8_lossy(&line).trim().to_string())
}
pub fn token_matches(expected: &str, got: &str) -> bool {
let (a, b) = (expected.as_bytes(), got.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Transport {
Unix,
NamedPipe,
}
impl Transport {
pub const fn native() -> Self {
#[cfg(windows)]
{
Transport::NamedPipe
}
#[cfg(not(windows))]
{
Transport::Unix
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub transport: Transport,
pub address: String,
}
impl Endpoint {
pub fn unix(path: impl Into<String>) -> Self {
Self {
transport: Transport::Unix,
address: path.into(),
}
}
pub fn named_pipe(name: impl Into<String>) -> Self {
Self {
transport: Transport::NamedPipe,
address: name.into(),
}
}
pub fn from_address(address: &str) -> Self {
if address.starts_with(r"\\") || address.starts_with("//") {
Self::named_pipe(address)
} else {
Self::unix(address)
}
}
}
impl std::fmt::Display for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.address)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct EndpointFile {
pub version: u32,
pub pid: u32,
#[serde(flatten)]
pub endpoint: Endpoint,
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
#[serde(default)]
pub started_unix_ms: u128,
}
impl EndpointFile {
pub fn new(endpoint: Endpoint, token: impl Into<String>) -> Self {
Self {
version: ENDPOINT_FILE_VERSION,
pid: std::process::id(),
endpoint,
token: token.into(),
app: std::env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())),
started_unix_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
}
}
pub fn dir() -> PathBuf {
let base = runtime_dir();
base.join("teksilo-automation")
}
pub fn path_for_pid(pid: u32) -> PathBuf {
Self::dir().join(format!("{pid}.json"))
}
pub fn write(&self) -> io::Result<PathBuf> {
let dir = Self::dir();
create_private_dir(&dir)?;
let path = Self::path_for_pid(self.pid);
let json = serde_json::to_vec_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
let _ = std::fs::remove_file(&tmp);
let mut file = private_file_options().create_new(true).open(&tmp)?;
if let Err(e) = check_dir_is_ours(&dir, &file) {
drop(file);
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::io::Write::write_all(&mut file, &json)?;
drop(file);
std::fs::rename(&tmp, &path)?;
Ok(path)
}
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
let parsed: Self = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if parsed.version != ENDPOINT_FILE_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"endpoint file version {} is not supported (this build speaks {ENDPOINT_FILE_VERSION})",
parsed.version
),
));
}
Ok(parsed)
}
pub fn list() -> Vec<Self> {
let Ok(entries) = std::fs::read_dir(Self::dir()) else {
return Vec::new();
};
let mut found: Vec<Self> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| Self::read(&e.path()).ok())
.collect();
found.sort_by_key(|f| std::cmp::Reverse(f.started_unix_ms));
found
}
pub fn remove(pid: u32) {
let _ = std::fs::remove_file(Self::path_for_pid(pid));
}
}
fn runtime_dir() -> PathBuf {
#[cfg(windows)]
{
std::env::var_os("LOCALAPPDATA")
.map(|local| PathBuf::from(local).join("Teksilo"))
.unwrap_or_else(std::env::temp_dir)
}
#[cfg(not(windows))]
{
if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
return PathBuf::from(xdg);
}
std::env::temp_dir()
}
}
pub fn create_private_dir(dir: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
let meta = std::fs::symlink_metadata(dir)?;
if !meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} exists but is not a directory", dir.display()),
));
}
if meta.permissions().mode() & 0o077 != 0 {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir)
}
}
#[allow(unused_variables)]
fn check_dir_is_ours(dir: &Path, owned_probe: &std::fs::File) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let ours = owned_probe.metadata()?.uid();
let theirs = std::fs::symlink_metadata(dir)?.uid();
if ours != theirs {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{} is owned by uid {theirs}, not by this user ({ours}) — refusing to publish \
the automation token into it",
dir.display()
),
));
}
}
Ok(())
}
fn private_file_options() -> std::fs::OpenOptions {
let mut opts = std::fs::OpenOptions::new();
opts.write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_round_trip() {
let mut buf = Vec::new();
write_frame(&mut buf, b"{\"op\":\"settle\"}", MAX_REQUEST_FRAME).unwrap();
let mut cursor = io::Cursor::new(buf);
let got = read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap();
assert_eq!(got, b"{\"op\":\"settle\"}");
}
#[test]
fn empty_frame_round_trips() {
let mut buf = Vec::new();
write_frame(&mut buf, b"", MAX_REQUEST_FRAME).unwrap();
assert_eq!(buf, vec![0, 0, 0, 0]);
let mut cursor = io::Cursor::new(buf);
assert!(
read_frame(&mut cursor, MAX_REQUEST_FRAME)
.unwrap()
.is_empty()
);
}
#[test]
fn several_frames_stream_back_in_order() {
let mut buf = Vec::new();
for n in 0..5u8 {
write_frame(&mut buf, &[n; 3], MAX_REQUEST_FRAME).unwrap();
}
let mut cursor = io::Cursor::new(buf);
for n in 0..5u8 {
assert_eq!(read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap(), [n; 3]);
}
let end = read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap_err();
assert_eq!(end.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn oversize_frame_is_refused_on_write_and_read() {
let mut buf = Vec::new();
let err = write_frame(&mut buf, &[0u8; 64], 16).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(buf.is_empty(), "nothing is emitted for a refused frame");
let mut hostile = Vec::new();
hostile.extend_from_slice(&u32::MAX.to_le_bytes());
let mut cursor = io::Cursor::new(hostile);
let err = read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn truncated_frame_is_an_error_not_a_short_read() {
let mut buf = Vec::new();
write_frame(&mut buf, b"0123456789", MAX_REQUEST_FRAME).unwrap();
buf.truncate(buf.len() - 4); let mut cursor = io::Cursor::new(buf);
let err = read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn token_line_round_trips_and_is_bounded() {
let mut buf = Vec::new();
write_token(&mut buf, "abc-123").unwrap();
assert_eq!(buf, b"abc-123\n");
let mut cursor = io::Cursor::new(buf);
assert_eq!(read_token(&mut cursor).unwrap(), "abc-123");
let flood = vec![b'x'; 4096];
let mut cursor = io::Cursor::new(flood);
let got = read_token(&mut cursor).unwrap();
assert_eq!(got.len(), MAX_TOKEN_LINE as usize);
}
#[test]
fn handshake_leaves_the_first_frame_intact() {
let mut buf = Vec::new();
write_token(&mut buf, "tok").unwrap();
write_frame(&mut buf, b"first", MAX_REQUEST_FRAME).unwrap();
write_frame(&mut buf, b"second", MAX_REQUEST_FRAME).unwrap();
let mut cursor = io::Cursor::new(buf);
assert_eq!(read_token(&mut cursor).unwrap(), "tok");
assert_eq!(
read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap(),
b"first"
);
assert_eq!(
read_frame(&mut cursor, MAX_REQUEST_FRAME).unwrap(),
b"second"
);
}
#[test]
fn token_comparison_rejects_wrong_and_short() {
assert!(token_matches("secret", "secret"));
assert!(!token_matches("secret", "secrer"));
assert!(!token_matches("secret", "secre"));
assert!(!token_matches("secret", ""));
}
#[test]
fn endpoint_address_shape_is_guessed_per_transport() {
assert_eq!(
Endpoint::from_address(r"\\.\pipe\teksilo-automation-9").transport,
Transport::NamedPipe
);
assert_eq!(
Endpoint::from_address("/run/user/1000/tka-9/s").transport,
Transport::Unix
);
}
#[test]
fn endpoint_file_round_trips_through_json() {
let ep = EndpointFile {
version: ENDPOINT_FILE_VERSION,
pid: 4242,
endpoint: Endpoint::named_pipe(r"\\.\pipe\teksilo-automation-4242"),
token: "tok".into(),
app: Some("widget-catalog".into()),
started_unix_ms: 17,
};
let json = serde_json::to_string(&ep).unwrap();
assert!(json.contains("\"transport\":\"named_pipe\""), "{json}");
assert!(json.contains("\"address\":"), "{json}");
assert_eq!(serde_json::from_str::<EndpointFile>(&json).unwrap(), ep);
}
#[test]
fn endpoint_file_rejects_a_future_version() {
let dir = std::env::temp_dir().join(format!("tka-wire-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("future.json");
std::fs::write(
&path,
br#"{"version":9999,"pid":1,"transport":"unix","address":"/x","token":"t"}"#,
)
.unwrap();
let err = EndpointFile::read(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn runtime_dir_is_absolute() {
assert!(
EndpointFile::dir().is_absolute(),
"{:?}",
EndpointFile::dir()
);
}
#[cfg(unix)]
#[test]
fn the_descriptor_is_never_briefly_world_readable() {
use std::os::unix::fs::PermissionsExt;
let file = EndpointFile::new(Endpoint::unix("/tmp/does-not-matter"), "tok");
let Ok(path) = file.write() else {
return; };
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the published descriptor must be owner-only");
let tmp = path.with_extension("json.tmp");
let staged = private_file_options().create_new(true).open(&tmp);
if let Ok(f) = staged {
let staged_mode = f.metadata().unwrap().permissions().mode() & 0o777;
let _ = std::fs::remove_file(&tmp);
assert_eq!(
staged_mode, 0o600,
"the staging file must be owner-only from the moment it exists"
);
}
EndpointFile::remove(file.pid);
}
}