Skip to main content

ios_core/services/syslog/
mod.rs

1//! Syslog relay service.
2//!
3//! Connects to `com.apple.syslog_relay` (or `.shim.remote` over tunnel)
4//! and streams null-byte-terminated log messages.
5//!
6//! Reference: go-ios/ios/syslog/syslog.go
7
8use tokio::io::{AsyncRead, AsyncReadExt};
9use tokio_stream::Stream;
10
11pub const SERVICE_NAME: &str = "com.apple.syslog_relay";
12pub const SHIM_SERVICE_NAME: &str = "com.apple.syslog_relay.shim.remote";
13
14/// Syslog stream that yields raw log message strings (null-byte terminated by the device).
15pub struct SyslogStream<S> {
16    stream: S,
17    buf: Vec<u8>,
18}
19
20impl<S: AsyncRead + Unpin> SyslogStream<S> {
21    pub fn new(stream: S) -> Self {
22        Self {
23            stream,
24            buf: Vec::with_capacity(4096),
25        }
26    }
27
28    /// Read one null-terminated log message (blocking until available).
29    pub async fn next_message(&mut self) -> Result<String, std::io::Error> {
30        let mut byte = [0u8; 1];
31        loop {
32            self.stream.read_exact(&mut byte).await?;
33            if byte[0] == 0 {
34                let msg = String::from_utf8_lossy(&self.buf).into_owned();
35                self.buf.clear();
36                return Ok(msg);
37            }
38            self.buf.push(byte[0]);
39        }
40    }
41}
42
43/// Convert a syslog stream into an async Stream<Item = Result<String, io::Error>>.
44pub fn into_stream<S: AsyncRead + Unpin + Send + 'static>(
45    stream: S,
46) -> impl Stream<Item = Result<String, std::io::Error>> {
47    async_stream::try_stream! {
48        let mut syslog = SyslogStream::new(stream);
49        loop {
50            let msg = syslog.next_message().await?;
51            if !msg.is_empty() {
52                yield msg;
53            }
54        }
55    }
56}
57
58/// Parsed syslog entry.
59#[derive(Debug, Clone)]
60pub struct LogEntry {
61    pub raw: String,
62    pub timestamp: Option<String>,
63    pub device: Option<String>,
64    pub process: Option<String>,
65    pub pid: Option<u32>,
66    pub level: Option<String>,
67    pub message: Option<String>,
68    pub parse_success: bool,
69    pub parse_error: Option<String>,
70}
71
72impl LogEntry {
73    /// Try to parse a raw syslog line into structured fields.
74    /// Preserves partial fields and makes parse failures explicit.
75    pub fn parse(raw: String) -> Self {
76        let mut entry = Self {
77            raw,
78            timestamp: None,
79            device: None,
80            process: None,
81            pid: None,
82            level: None,
83            message: None,
84            parse_success: false,
85            parse_error: None,
86        };
87
88        entry.level = extract_level(&entry.raw);
89        entry.message = extract_message(&entry.raw);
90
91        let Some((timestamp, device, remainder)) = extract_prefix(&entry.raw) else {
92            entry.parse_error = Some("missing syslog prefix (timestamp/device)".to_string());
93            return entry;
94        };
95
96        entry.timestamp = Some(timestamp);
97        entry.device = Some(device);
98
99        let Some((process, pid)) = extract_process_token(remainder) else {
100            entry.parse_error = Some("missing process segment after device".to_string());
101            return entry;
102        };
103
104        entry.process = Some(process);
105        entry.pid = pid;
106        entry.parse_success = true;
107        entry
108    }
109}
110
111fn extract_prefix(s: &str) -> Option<(String, String, &str)> {
112    let mut cursor = 0;
113    let month = take_token(s, &mut cursor)?;
114    let day = take_token(s, &mut cursor)?;
115    let time = take_token(s, &mut cursor)?;
116    let device = take_token(s, &mut cursor)?;
117    let remainder = s[cursor..].trim_start();
118    if remainder.is_empty() {
119        return None;
120    }
121
122    Some((
123        format!("{month} {day} {time}"),
124        device.to_string(),
125        remainder,
126    ))
127}
128
129fn take_token<'a>(s: &'a str, cursor: &mut usize) -> Option<&'a str> {
130    let bytes = s.as_bytes();
131    while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
132        *cursor += 1;
133    }
134    if *cursor >= bytes.len() {
135        return None;
136    }
137
138    let start = *cursor;
139    while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
140        *cursor += 1;
141    }
142    Some(&s[start..*cursor])
143}
144
145fn extract_process_token(s: &str) -> Option<(String, Option<u32>)> {
146    let token = s.split_whitespace().next()?.trim();
147    if token.is_empty() {
148        return None;
149    }
150
151    if let Some(open) = token.rfind('[') {
152        if token.ends_with(']') && open > 0 {
153            let pid = token[open + 1..token.len() - 1].parse().ok();
154            let process = token[..open].trim();
155            if !process.is_empty() {
156                return Some((process.to_string(), pid));
157            }
158        }
159    }
160
161    Some((token.to_string(), None))
162}
163
164fn extract_level(s: &str) -> Option<String> {
165    let start = s.find('<')? + 1;
166    let end = s[start..].find('>')? + start;
167    Some(s[start..end].to_string())
168}
169
170fn extract_message(s: &str) -> Option<String> {
171    if let Some(pos) = s.find(">: ") {
172        return Some(s[pos + 3..].to_string());
173    }
174    if let Some(pos) = s.find(">:") {
175        return Some(s[pos + 2..].trim_start().to_string());
176    }
177    None
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[tokio::test]
185    async fn test_syslog_stream_null_terminated() {
186        // Two null-terminated messages
187        let data = b"message one\0message two\0";
188        let mut stream = std::io::Cursor::new(data);
189        let mut syslog = SyslogStream::new(&mut stream);
190        assert_eq!(syslog.next_message().await.unwrap(), "message one");
191        assert_eq!(syslog.next_message().await.unwrap(), "message two");
192    }
193
194    #[test]
195    fn test_log_entry_parse_level() {
196        let raw = "Mar 17 12:34:56 iPhone kernel[0] <Notice>: boot message";
197        let entry = LogEntry::parse(raw.to_string());
198        assert_eq!(entry.timestamp.as_deref(), Some("Mar 17 12:34:56"));
199        assert_eq!(entry.device.as_deref(), Some("iPhone"));
200        assert_eq!(entry.process.as_deref(), Some("kernel"));
201        assert_eq!(entry.level.as_deref(), Some("Notice"));
202        assert_eq!(entry.pid, Some(0));
203        assert_eq!(entry.message.as_deref(), Some("boot message"));
204        assert!(entry.parse_success);
205        assert_eq!(entry.parse_error, None);
206    }
207
208    #[test]
209    fn test_log_entry_parse_failure_is_explicit() {
210        let raw = "totally unstructured syslog payload";
211        let entry = LogEntry::parse(raw.to_string());
212
213        assert_eq!(entry.raw, raw);
214        assert_eq!(entry.timestamp, None);
215        assert_eq!(entry.device, None);
216        assert_eq!(entry.process, None);
217        assert_eq!(entry.pid, None);
218        assert_eq!(entry.level, None);
219        assert_eq!(entry.message, None);
220        assert!(!entry.parse_success);
221        assert!(entry.parse_error.as_deref().is_some());
222    }
223}