ios_core/services/syslog/
mod.rs1use 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
14pub 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 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
43pub 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#[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 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 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}