use serde::{Deserialize, Serialize};
use crate::error::ServiceError;
use crate::models::StopReason;
pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
pub const PLUGIN_IPC_VERSION: u32 = 1;
pub(crate) const VERSION_MISMATCH_CODE: &str = "version_mismatch";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum IpcRequest {
Start {
config: crate::models::StartConfig,
},
Stop,
IsRunning,
GetState,
EnableAutoRestart {
config: Option<crate::models::StartConfig>,
},
DisableAutoRestart,
GetDesiredState,
ValidateSetup,
GetLifecycleStatus,
Hello {
#[serde(default)]
version: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IpcResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
pub(crate) fn version_mismatch_response() -> IpcResponse {
IpcResponse {
ok: false,
data: None,
error: Some("version mismatch".into()),
code: Some(VERSION_MISMATCH_CODE.to_string()),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum IpcEvent {
Started,
Stopped { reason: StopReason },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum IpcMessage {
Request(IpcRequest),
Response(IpcResponse),
Event(IpcEvent),
}
pub fn encode_frame<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
let json = serde_json::to_vec(msg)?;
let len = json.len() as u32;
let mut buf = Vec::with_capacity(4 + json.len());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(&json);
Ok(buf)
}
pub fn decode_frame(payload: &[u8]) -> Result<IpcMessage, serde_json::Error> {
serde_json::from_slice(payload)
}
pub fn socket_path(label: &str) -> Result<std::path::PathBuf, ServiceError> {
sanitize_label(label)?;
#[cfg(target_os = "linux")]
{
let dir = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
Ok(std::path::PathBuf::from(format!("{dir}/{label}.sock")))
}
#[cfg(target_os = "macos")]
{
Ok(std::path::PathBuf::from(format!("/tmp/{label}.sock")))
}
#[cfg(windows)]
{
let _ = label;
Err(ServiceError::Platform(
"OS-service socket path is not available on Windows (DESK-01)".into(),
))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
{
Ok(std::path::PathBuf::from(format!("/tmp/{label}.sock")))
}
}
fn sanitize_label(label: &str) -> Result<std::path::PathBuf, ServiceError> {
if label.is_empty() {
return Err(ServiceError::Init("service label must not be empty".into()));
}
if label.contains('/') || label.contains('\\') {
return Err(ServiceError::Init(format!(
"service label must not contain path separators: {label}"
)));
}
if label.contains("..") {
return Err(ServiceError::Init(format!(
"service label must not contain '..': {label}"
)));
}
Ok(std::path::PathBuf::from(label))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipc_request_start_serde_roundtrip() {
let req = IpcRequest::Start {
config: crate::models::StartConfig {
service_label: "Syncing".into(),
foreground_service_type: "dataSync".into(),
},
};
let json = serde_json::to_string(&req).unwrap();
let de: IpcRequest = serde_json::from_str(&json).unwrap();
match de {
IpcRequest::Start { config } => {
assert_eq!(config.service_label, "Syncing");
assert_eq!(config.foreground_service_type, "dataSync");
}
other => panic!("Expected Start, got {other:?}"),
}
}
#[test]
fn ipc_request_start_json_tag() {
let req = IpcRequest::Start {
config: crate::models::StartConfig::default(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"type\":\"start\""), "Tagged JSON: {json}");
}
#[test]
fn ipc_request_stop_serde_roundtrip() {
let req = IpcRequest::Stop;
let json = serde_json::to_string(&req).unwrap();
let de: IpcRequest = serde_json::from_str(&json).unwrap();
assert!(matches!(de, IpcRequest::Stop));
}
#[test]
fn ipc_request_stop_json_tag() {
let req = IpcRequest::Stop;
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"type\":\"stop\""), "Tagged JSON: {json}");
}
#[test]
fn ipc_request_is_running_serde_roundtrip() {
let req = IpcRequest::IsRunning;
let json = serde_json::to_string(&req).unwrap();
let de: IpcRequest = serde_json::from_str(&json).unwrap();
assert!(matches!(de, IpcRequest::IsRunning));
}
#[test]
fn ipc_request_is_running_json_tag() {
let req = IpcRequest::IsRunning;
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("\"type\":\"isRunning\""),
"Tagged JSON: {json}"
);
}
#[test]
fn ipc_request_get_state_serde_roundtrip() {
let req = IpcRequest::GetState;
let json = serde_json::to_string(&req).unwrap();
let de: IpcRequest = serde_json::from_str(&json).unwrap();
assert!(matches!(de, IpcRequest::GetState));
}
#[test]
fn ipc_request_get_state_json_tag() {
let req = IpcRequest::GetState;
let json = serde_json::to_string(&req).unwrap();
assert!(
json.contains("\"type\":\"getState\""),
"Tagged JSON: {json}"
);
}
#[test]
fn ipc_response_success_roundtrip() {
let resp = IpcResponse {
ok: true,
data: Some(serde_json::json!({"running": true})),
error: None,
code: None,
};
let json = serde_json::to_string(&resp).unwrap();
let de: IpcResponse = serde_json::from_str(&json).unwrap();
assert!(de.ok);
assert_eq!(de.data.unwrap()["running"], true);
assert!(de.error.is_none());
}
#[test]
fn ipc_response_error_roundtrip() {
let resp = IpcResponse {
ok: false,
data: None,
error: Some("Service is already running".into()),
code: None,
};
let json = serde_json::to_string(&resp).unwrap();
let de: IpcResponse = serde_json::from_str(&json).unwrap();
assert!(!de.ok);
assert!(de.data.is_none());
assert_eq!(de.error.unwrap(), "Service is already running");
}
#[test]
fn ipc_response_skips_none_fields() {
let resp = IpcResponse {
ok: true,
data: None,
error: None,
code: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(!json.contains("\"data\""), "Should skip null data: {json}");
assert!(
!json.contains("\"error\""),
"Should skip null error: {json}"
);
}
#[test]
fn ipc_response_camel_case_keys() {
let resp = IpcResponse {
ok: true,
data: None,
error: None,
code: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"ok\""), "ok key: {json}");
}
#[test]
fn ipc_event_started_serde_roundtrip() {
let event = IpcEvent::Started;
let json = serde_json::to_string(&event).unwrap();
let de: IpcEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(de, IpcEvent::Started));
}
#[test]
fn ipc_event_started_json_tag() {
let event = IpcEvent::Started;
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
}
#[test]
fn ipc_event_stopped_serde_roundtrip() {
let event = IpcEvent::Stopped {
reason: StopReason::UserStop,
};
let json = serde_json::to_string(&event).unwrap();
let de: IpcEvent = serde_json::from_str(&json).unwrap();
match de {
IpcEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
other => panic!("Expected Stopped, got {other:?}"),
}
}
#[test]
fn ipc_event_stopped_json_keys() {
let event = IpcEvent::Stopped {
reason: StopReason::TaskCompleted,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
assert!(
json.contains("\"reason\":\"taskCompleted\""),
"Reason: {json}"
);
}
#[test]
fn ipc_event_error_serde_roundtrip() {
let event = IpcEvent::Error {
message: "init failed".into(),
};
let json = serde_json::to_string(&event).unwrap();
let de: IpcEvent = serde_json::from_str(&json).unwrap();
match de {
IpcEvent::Error { message } => assert_eq!(message, "init failed"),
other => panic!("Expected Error, got {other:?}"),
}
}
#[test]
fn ipc_event_error_json_keys() {
let event = IpcEvent::Error {
message: "oops".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
}
#[test]
fn ipc_frame_encode_decode_request() {
let req = IpcRequest::Start {
config: crate::models::StartConfig::default(),
};
let msg = IpcMessage::Request(req);
let encoded = encode_frame(&msg).unwrap();
let decoded = decode_frame(&encoded[4..]).unwrap();
match decoded {
IpcMessage::Request(IpcRequest::Start { config }) => {
assert_eq!(config.service_label, "Service running");
}
other => panic!("Expected Request(Start), got {other:?}"),
}
}
#[test]
fn ipc_frame_encode_decode_response() {
let resp = IpcResponse {
ok: true,
data: Some(serde_json::json!(42)),
error: None,
code: None,
};
let msg = IpcMessage::Response(resp);
let encoded = encode_frame(&msg).unwrap();
let decoded = decode_frame(&encoded[4..]).unwrap();
match decoded {
IpcMessage::Response(r) => {
assert!(r.ok);
assert_eq!(r.data.unwrap(), 42);
}
other => panic!("Expected Response, got {other:?}"),
}
}
#[test]
fn ipc_frame_encode_decode_event() {
let event = IpcEvent::Stopped {
reason: StopReason::TaskCompleted,
};
let msg = IpcMessage::Event(event);
let encoded = encode_frame(&msg).unwrap();
let decoded = decode_frame(&encoded[4..]).unwrap();
match decoded {
IpcMessage::Event(IpcEvent::Stopped { reason }) => {
assert_eq!(reason, StopReason::TaskCompleted)
}
other => panic!("Expected Event(Stopped), got {other:?}"),
}
}
#[test]
fn ipc_frame_length_prefix_is_big_endian() {
let req = IpcRequest::Stop;
let encoded = encode_frame(&req).unwrap();
let len = u32::from_be_bytes([encoded[0], encoded[1], encoded[2], encoded[3]]);
assert_eq!(len as usize, encoded.len() - 4);
}
#[test]
fn ipc_frame_decode_payload_without_length_prefix() {
let resp = IpcResponse {
ok: true,
data: Some(serde_json::json!({"status": "ok"})),
error: None,
code: None,
};
let msg = IpcMessage::Response(resp);
let payload = serde_json::to_vec(&msg).unwrap();
let decoded = decode_frame(&payload).unwrap();
match decoded {
IpcMessage::Response(r) => {
assert!(r.ok);
assert_eq!(r.data.unwrap()["status"], "ok");
}
other => panic!("Expected Response, got {other:?}"),
}
}
#[test]
fn ipc_frame_malformed_json() {
let payload = b"{invalid";
let result = decode_frame(payload);
assert!(result.is_err(), "Expected JSON error for malformed payload");
}
#[test]
fn ipc_message_response_roundtrip() {
let msg = IpcMessage::Response(IpcResponse {
ok: true,
data: Some(serde_json::json!({"running": true})),
error: None,
code: None,
});
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"kind\":\"response\""), "kind tag: {json}");
let de: IpcMessage = serde_json::from_str(&json).unwrap();
match de {
IpcMessage::Response(resp) => {
assert!(resp.ok);
assert_eq!(resp.data.unwrap()["running"], true);
}
other => panic!("Expected Response, got {other:?}"),
}
}
#[test]
fn ipc_message_event_roundtrip() {
let msg = IpcMessage::Event(IpcEvent::Stopped {
reason: StopReason::UserStop,
});
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"kind\":\"event\""), "kind tag: {json}");
let de: IpcMessage = serde_json::from_str(&json).unwrap();
match de {
IpcMessage::Event(IpcEvent::Stopped { reason }) => {
assert_eq!(reason, StopReason::UserStop);
}
other => panic!("Expected Event, got {other:?}"),
}
}
#[test]
fn ipc_message_ambiguous_frame_deterministic() {
let json = r#"{"kind":"event","type":"started","ok":true}"#;
let de: IpcMessage = serde_json::from_str(json).unwrap();
match de {
IpcMessage::Event(IpcEvent::Started) => {} other => panic!("Expected Event::Started, got {other:?}"),
}
let json2 = r#"{"kind":"response","ok":true,"data":{"type":"started"}}"#;
let de2: IpcMessage = serde_json::from_str(json2).unwrap();
match de2 {
IpcMessage::Response(resp) => {
assert!(resp.ok);
}
other => panic!("Expected Response, got {other:?}"),
}
}
#[test]
fn ipc_message_unknown_kind_rejected() {
let json = r#"{"kind":"unknown","ok":true}"#;
let result: Result<IpcMessage, _> = serde_json::from_str(json);
assert!(result.is_err(), "Expected error for unknown kind value");
}
#[cfg(unix)]
#[test]
fn socket_path_unix_format() {
let path = socket_path("com.example.svc").unwrap();
#[cfg(target_os = "linux")]
{
let path_str = path.to_str().unwrap();
assert!(
path_str.ends_with("/com.example.svc.sock"),
"Expected path ending with /com.example.svc.sock, got: {path_str}"
);
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
assert!(
path_str.starts_with(&xdg),
"Expected path under XDG_RUNTIME_DIR ({xdg}), got: {path_str}"
);
} else {
assert!(
path_str.contains("/run/user/"),
"Expected fallback /run/user/ path, got: {path_str}"
);
}
}
#[cfg(target_os = "macos")]
{
assert_eq!(path.to_str().unwrap(), "/tmp/com.example.svc.sock");
}
}
#[cfg(windows)]
#[test]
fn socket_path_windows_pipe_format() {
let path = socket_path("com.example.svc").unwrap();
assert_eq!(
path.to_str().unwrap(),
r"\\.\pipe\com.example.svc",
"Windows must derive a named-pipe path, not a Unix socket path"
);
}
#[test]
fn socket_path_nonempty_label() {
let path = socket_path("my-app").unwrap();
#[cfg(unix)]
assert!(
path.to_str().unwrap().ends_with("my-app.sock"),
"Expected path ending with my-app.sock, got: {:?}",
path
);
#[cfg(windows)]
assert_eq!(path.to_str().unwrap(), r"\\.\pipe\my-app");
}
#[test]
fn socket_path_rejects_slash_in_label() {
let result = socket_path("../etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("path separators"), "Error: {err}");
}
#[test]
fn socket_path_rejects_dotdot_in_label() {
let result = socket_path("..");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("'..'"), "Error: {err}");
}
#[test]
fn socket_path_rejects_backslash_in_label() {
let result = socket_path("foo\\bar");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("path separators"), "Error: {err}");
}
#[test]
fn socket_path_rejects_empty_label() {
let result = socket_path("");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("empty"), "Error: {err}");
}
#[test]
fn ipc_request_hello_serde_roundtrip() {
let req = IpcRequest::Hello {
version: PLUGIN_IPC_VERSION,
};
let json = serde_json::to_string(&req).unwrap();
let de: IpcRequest = serde_json::from_str(&json).unwrap();
match de {
IpcRequest::Hello { version } => assert_eq!(version, PLUGIN_IPC_VERSION),
other => panic!("Expected Hello, got {other:?}"),
}
let de2: IpcRequest = serde_json::from_str(r#"{"type":"hello"}"#).unwrap();
match de2 {
IpcRequest::Hello { version } => assert_eq!(version, 0, "default u32 is 0"),
other => panic!("Expected Hello (defaulted), got {other:?}"),
}
}
#[test]
fn ipc_request_hello_json_tag() {
let req = IpcRequest::Hello {
version: PLUGIN_IPC_VERSION,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"type\":\"hello\""), "Tagged JSON: {json}");
assert!(
json.contains("\"version\":1"),
"version field present: {json}"
);
}
#[test]
fn ipc_response_code_field_is_additive_on_wire() {
let resp = IpcResponse {
ok: false,
data: None,
error: Some("boom".into()),
code: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
!json.contains("\"code\""),
"None code must be omitted: {json}"
);
let resp2 = IpcResponse {
ok: false,
data: None,
error: Some("version mismatch".into()),
code: Some(VERSION_MISMATCH_CODE.into()),
};
let json2 = serde_json::to_string(&resp2).unwrap();
assert!(json2.contains("\"code\":\"version_mismatch\""), "{json2}");
let de: IpcResponse = serde_json::from_str(&json2).unwrap();
assert_eq!(de.code.as_deref(), Some(VERSION_MISMATCH_CODE));
let legacy = r#"{"ok":false,"error":"legacy"}"#;
let de2: IpcResponse = serde_json::from_str(legacy).unwrap();
assert!(de2.code.is_none(), "legacy frame must decode to code: None");
assert_eq!(de2.error.as_deref(), Some("legacy"));
}
#[test]
fn ipc_request_unknown_variant_yields_stable_mismatch_code() {
let skew = br#"{"type":"someFutureVariant"}"#;
let decode_err = serde_json::from_slice::<IpcRequest>(skew);
assert!(
decode_err.is_err(),
"unknown variant must fail serde decode (skew), got {:?}",
decode_err
);
let resp = version_mismatch_response();
assert!(!resp.ok);
assert_eq!(
resp.code.as_deref(),
Some(VERSION_MISMATCH_CODE),
"decode failure must carry the stable mismatch code"
);
assert!(
resp.error.is_some(),
"error must be Some for decode failures"
);
let opaque = decode_err.unwrap_err().to_string();
assert!(
!resp.error.as_deref().unwrap_or_default().contains(&opaque),
"response must not leak the opaque serde string: {:?}",
resp.error
);
}
}