1use crate::{
3 MAX_PIXELS, Raster, RasterOptions,
4 mermaid::{self, MAX_SOURCE},
5};
6use std::{
7 io::{self, Read, Write},
8 path::Path,
9 process::{Child, Command, Stdio},
10 sync::{
11 atomic::{AtomicBool, Ordering},
12 mpsc,
13 },
14 thread::{self, JoinHandle},
15 time::{Duration, Instant},
16};
17pub const MAX_REQUESTS: usize = 32;
19#[cfg(target_os = "macos")]
20const MAX_RESIDENT_BYTES: u64 = 256 * 1024 * 1024;
21
22#[cfg(target_os = "macos")]
23fn resident_bytes(pid: u32) -> Option<u64> {
24 let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::uninit();
25 let size = std::mem::size_of::<libc::proc_taskinfo>() as i32;
26 let count = unsafe {
28 libc::proc_pidinfo(
29 pid as i32,
30 libc::PROC_PIDTASKINFO,
31 0,
32 info.as_mut_ptr().cast(),
33 size,
34 )
35 };
36 if count == size {
37 Some(unsafe { info.assume_init() }.pti_resident_size)
38 } else {
39 None
40 }
41}
42
43pub fn worker_main() -> io::Result<()> {
45 restrict_worker()?;
46 serve(&mut io::stdin().lock(), &mut io::stdout().lock())
47}
48
49fn restrict_worker() -> io::Result<()> {
50 #[cfg(unix)]
51 {
52 for (resource, seconds) in [(libc::RLIMIT_CPU, 8), (libc::RLIMIT_CORE, 0)] {
55 let limit = libc::rlimit {
56 rlim_cur: seconds,
57 rlim_max: seconds,
58 };
59 if unsafe { libc::setrlimit(resource, &limit) } != 0 {
60 return Err(io::Error::last_os_error());
61 }
62 }
63 unsafe {
65 libc::setpriority(libc::PRIO_PROCESS, 0, 10);
66 }
67 }
68 #[cfg(target_os = "macos")]
69 {
70 use std::ffi::{CStr, c_char};
71 unsafe extern "C" {
72 fn sandbox_init(profile: *const c_char, flags: u64, error: *mut *mut c_char) -> i32;
73 fn sandbox_free_error(error: *mut c_char);
74 }
75 let profile = c"(version 1)(allow default)(deny network*)(deny file-write*)(deny file-read-data)(deny process-exec)(deny process-fork)";
78 let mut error = std::ptr::null_mut();
79 if unsafe { sandbox_init(profile.as_ptr(), 0, &mut error) } != 0 {
81 let message = if error.is_null() {
82 "Cannot restrict diagram worker".into()
83 } else {
84 unsafe { CStr::from_ptr(error) }
85 .to_string_lossy()
86 .into_owned()
87 };
88 if !error.is_null() {
89 unsafe { sandbox_free_error(error) };
90 }
91 return Err(io::Error::other(message));
92 }
93 }
94 Ok(())
95}
96
97fn serve(input: &mut impl Read, output: &mut impl Write) -> io::Result<()> {
98 for _ in 0..MAX_REQUESTS {
99 let mut header = [0; 25];
100 match input.read(&mut header[..1])? {
101 0 => return Ok(()),
102 _ => input.read_exact(&mut header[1..])?,
103 }
104 let number = |slice: &[u8]| u32::from_le_bytes(slice.try_into().unwrap());
105 let length = number(&header[12..16]) as usize;
106 let options = RasterOptions {
107 bounds: (number(&header[4..8]), number(&header[8..12])),
108 scale: f64::from_le_bytes(header[17..25].try_into().unwrap()),
109 };
110 if &header[..4] != b"NPV1"
111 || !options.valid()
112 || length == 0
113 || length > MAX_SOURCE
114 || header[16] > 1
115 {
116 return Err(io::Error::new(
117 io::ErrorKind::InvalidData,
118 "Invalid diagram request",
119 ));
120 }
121 let mut source = vec![0; length];
122 input.read_exact(&mut source)?;
123 let pixels = std::str::from_utf8(&source)
124 .ok()
125 .and_then(|s| mermaid::render(s, header[16] == 1, options));
126 if let Some(p) = pixels {
127 for n in [
128 p.width,
129 p.height,
130 p.rgba.len() as u32,
131 p.original_width,
132 p.original_height,
133 ] {
134 output.write_all(&n.to_le_bytes())?;
135 }
136 output.write_all(&p.rgba)?;
137 } else {
138 output.write_all(&[0; 20])?;
139 }
140 output.flush()?;
141 }
142 Ok(())
143}
144
145fn response(input: &mut impl Read, bounds: (u32, u32)) -> io::Result<Option<Raster>> {
146 let mut header = [0; 20];
147 input.read_exact(&mut header)?;
148 let number = |slice: &[u8]| u32::from_le_bytes(slice.try_into().unwrap());
149 let (width, height, length) = (
150 number(&header[..4]),
151 number(&header[4..8]),
152 number(&header[8..12]) as usize,
153 );
154 let original_width = number(&header[12..16]);
155 let original_height = number(&header[16..20]);
156 if width == 0 && height == 0 && length == 0 && original_width == 0 && original_height == 0 {
157 return Ok(None);
158 }
159 let pixels = u64::from(width) * u64::from(height);
160 if original_width == 0
161 || original_height == 0
162 || original_width > 8192
163 || original_height > 8192
164 || u64::from(original_width) * u64::from(original_height) > 4_000_000
165 || width == 0
166 || height == 0
167 || width > bounds.0
168 || height > bounds.1
169 || pixels > MAX_PIXELS as u64
170 || pixels * 4 != length as u64
171 {
172 return Err(io::Error::new(
173 io::ErrorKind::InvalidData,
174 "invalid Mermaid pixels",
175 ));
176 }
177 let mut rgba = vec![0; length];
178 input.read_exact(&mut rgba)?;
179 Ok(Some(Raster {
180 original_width,
181 original_height,
182 width,
183 height,
184 rgba,
185 }))
186}
187
188type Request = (Vec<u8>, (u32, u32));
189pub struct Worker {
192 child: Child,
193 requests: Option<mpsc::SyncSender<Request>>,
194 replies: mpsc::Receiver<io::Result<Option<Raster>>>,
195 thread: Option<JoinHandle<()>>,
196 completed: usize,
197}
198impl Worker {
199 pub fn requests_completed(&self) -> usize {
200 self.completed
201 }
202 pub fn spawn(program: &Path, worker_argument: &str) -> io::Result<Self> {
203 let child = Command::new(program)
204 .arg(worker_argument)
205 .env_clear()
206 .stdin(Stdio::piped())
207 .stdout(Stdio::piped())
208 .stderr(Stdio::null())
209 .spawn()?;
210 Self::connect(child)
211 }
212
213 fn connect(mut child: Child) -> io::Result<Self> {
214 let mut input = child.stdin.take().unwrap();
215 let mut output = child.stdout.take().unwrap();
216 let (requests, incoming) = mpsc::sync_channel::<Request>(1);
217 let (replies, outgoing) = mpsc::sync_channel(1);
218 let thread = thread::Builder::new()
219 .name("mermaid-io".into())
220 .spawn(move || {
221 while let Ok((message, bounds)) = incoming.recv() {
222 let result = input
223 .write_all(&message)
224 .and_then(|()| input.flush())
225 .and_then(|()| response(&mut output, bounds));
226 let failed = result.is_err();
227 if replies.send(result).is_err() || failed {
228 break;
229 }
230 }
231 });
232 match thread {
233 Ok(thread) => Ok(Self {
234 child,
235 requests: Some(requests),
236 replies: outgoing,
237 thread: Some(thread),
238 completed: 0,
239 }),
240 Err(error) => {
241 let _ = child.kill();
242 let _ = child.wait();
243 Err(error)
244 }
245 }
246 }
247
248 pub fn render(
251 &mut self,
252 source: &str,
253 light: bool,
254 options: RasterOptions,
255 timeout: Duration,
256 cancel: &AtomicBool,
257 ) -> io::Result<Option<Raster>> {
258 let result = self.render_inner(source, light, options, timeout, cancel);
259 if result.is_err() {
260 self.stop();
261 }
262 result
263 }
264 fn render_inner(
265 &mut self,
266 source: &str,
267 light: bool,
268 options: RasterOptions,
269 timeout: Duration,
270 cancel: &AtomicBool,
271 ) -> io::Result<Option<Raster>> {
272 if self.requests.is_none() {
273 return Err(io::Error::other("Diagram worker stopped"));
274 }
275 if cancel.load(Ordering::Relaxed) {
276 return Err(io::ErrorKind::Interrupted.into());
277 }
278 if !options.valid() {
279 return Err(io::Error::new(
280 io::ErrorKind::InvalidInput,
281 "Invalid raster options",
282 ));
283 }
284 if !mermaid::admissible(source) {
285 return Ok(None);
286 }
287 if self.completed >= MAX_REQUESTS {
288 return Err(io::Error::other("Diagram worker request limit reached"));
289 }
290 let deadline = Instant::now()
291 .checked_add(timeout)
292 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid render timeout"))?;
293 let bounds = options.bounds;
294 let mut message = Vec::with_capacity(25 + source.len());
295 message.extend_from_slice(b"NPV1");
296 message.extend_from_slice(&bounds.0.to_le_bytes());
297 message.extend_from_slice(&bounds.1.to_le_bytes());
298 message.extend_from_slice(&(source.len() as u32).to_le_bytes());
299 message.push(u8::from(light));
300 message.extend_from_slice(&options.scale.to_le_bytes());
301 message.extend_from_slice(source.as_bytes());
302 self.requests
303 .as_ref()
304 .unwrap()
305 .try_send((message, bounds))
306 .map_err(|_| io::Error::other("Mermaid worker unavailable"))?;
307 loop {
308 if cancel.load(Ordering::Relaxed) {
309 return Err(io::Error::new(
310 io::ErrorKind::Interrupted,
311 "Diagram cancelled",
312 ));
313 }
314 if Instant::now() >= deadline {
315 return Err(io::Error::new(io::ErrorKind::TimedOut, "Diagram timed out"));
316 }
317 #[cfg(target_os = "macos")]
318 if resident_bytes(self.child.id()).is_some_and(|n| n > MAX_RESIDENT_BYTES) {
319 return Err(io::Error::other("Diagram exceeded its memory limit"));
320 }
321 let remaining = deadline.saturating_duration_since(Instant::now());
322 match self
323 .replies
324 .recv_timeout(remaining.min(Duration::from_millis(20)))
325 {
326 Ok(result) => {
327 if cancel.load(Ordering::Relaxed) {
330 return Err(io::ErrorKind::Interrupted.into());
331 }
332 if Instant::now() >= deadline {
333 return Err(io::ErrorKind::TimedOut.into());
334 }
335 self.completed += 1;
336 return result;
337 }
338 Err(mpsc::RecvTimeoutError::Timeout) => {}
339 Err(_) => return Err(io::Error::other("Diagram worker stopped")),
340 }
341 }
342 }
343 fn stop(&mut self) {
344 let _ = self.child.kill();
347 let _ = self.child.wait();
348 self.requests.take();
349 if let Some(thread) = self.thread.take() {
350 let _ = thread.join();
351 }
352 }
353}
354
355impl Drop for Worker {
356 fn drop(&mut self) {
357 self.stop();
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::MAX_EDGE;
365 #[cfg(unix)]
366 use std::sync::Arc;
367 #[test]
368 fn worker_protocol_rejects_invalid_or_truncated_data_before_allocating() {
369 let mut data = Vec::new();
370 for n in [2u32, 1, 8, 1, 1] {
371 data.extend_from_slice(&n.to_le_bytes());
372 }
373 data.extend_from_slice(&[255, 0, 0, 128, 0, 0, 255, 255]);
374 let p = response(&mut &data[..], (MAX_EDGE, MAX_EDGE))
375 .unwrap()
376 .unwrap();
377 assert_eq!((p.width, p.height), (2, 1));
378 assert_eq!(p.rgba, data[20..]);
379 assert!(response(&mut &data[..27], (MAX_EDGE, MAX_EDGE)).is_err());
380 assert!(response(&mut &data[..], (1, 1)).is_err());
381 assert!(
382 response(&mut &[0; 20][..], (MAX_EDGE, MAX_EDGE))
383 .unwrap()
384 .is_none()
385 );
386 data[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
387 assert!(response(&mut &data[..], (MAX_EDGE, MAX_EDGE)).is_err());
388 let mut request = Vec::from(&b"NPV1"[..]);
389 for n in [MAX_EDGE, MAX_EDGE, MAX_SOURCE as u32 + 1] {
390 request.extend_from_slice(&n.to_le_bytes());
391 }
392 request.push(0);
393 request.extend_from_slice(&1_f64.to_le_bytes());
394 let mut output = Vec::new();
395 assert!(serve(&mut &request[..], &mut output).is_err());
396 assert!(output.is_empty());
397 }
398 #[cfg(unix)]
399 #[test]
400 fn a_reply_after_a_short_deadline_is_rejected() {
401 let child = Command::new("sleep")
402 .arg("30")
403 .stdin(Stdio::piped())
404 .stdout(Stdio::piped())
405 .spawn()
406 .unwrap();
407 let mut worker = Worker::connect(child).unwrap();
408 let (send, receive) = mpsc::sync_channel(1);
409 worker.replies = receive;
410 let delayed = thread::spawn(move || {
411 thread::sleep(Duration::from_millis(10));
412 let _ = send.send(Ok(None));
413 });
414 let error = worker
415 .render(
416 "flowchart LR; A-->B",
417 false,
418 RasterOptions::scaled(1.),
419 Duration::from_millis(1),
420 &AtomicBool::new(false),
421 )
422 .err()
423 .unwrap();
424 assert_eq!(error.kind(), io::ErrorKind::TimedOut);
425 assert!(worker.requests.is_none());
426 assert_eq!(worker.requests_completed(), 0);
427 delayed.join().unwrap();
428 }
429
430 #[cfg(unix)]
431 #[test]
432 fn timeouts_and_cancellation_kill_and_reap_partial_replies() {
433 for cancel_after in [None, Some(Duration::from_millis(30))] {
434 let child = Command::new("sh")
435 .args(["-c", "printf '\\001'; exec sleep 30"])
436 .stdin(Stdio::piped())
437 .stdout(Stdio::piped())
438 .stderr(Stdio::null())
439 .spawn()
440 .unwrap();
441 let mut worker = Worker::connect(child).unwrap();
442 let pid = worker.child.id();
443 let cancel = Arc::new(AtomicBool::new(false));
444 let cancellation = cancel_after.map(|duration| {
445 let cancel = cancel.clone();
446 thread::spawn(move || {
447 thread::sleep(duration);
448 cancel.store(true, Ordering::Relaxed);
449 })
450 });
451 let started = Instant::now();
452 let error = worker
453 .render(
454 "flowchart LR; A-->B",
455 false,
456 RasterOptions::scaled(1.),
457 Duration::from_millis(100),
458 &cancel,
459 )
460 .err()
461 .unwrap();
462 assert_eq!(
463 error.kind(),
464 if cancel_after.is_some() {
465 io::ErrorKind::Interrupted
466 } else {
467 io::ErrorKind::TimedOut
468 }
469 );
470 assert!(worker.requests.is_none());
471 if let Some(thread) = cancellation {
472 thread.join().unwrap();
473 }
474 assert!(started.elapsed() < Duration::from_secs(3));
475 assert_eq!(unsafe { libc::kill(pid as libc::pid_t, 0) }, -1);
477 assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH));
478 assert!(
479 worker
480 .render(
481 "flowchart LR; A-->B",
482 false,
483 RasterOptions::scaled(1.),
484 Duration::from_millis(100),
485 &AtomicBool::new(false)
486 )
487 .is_err()
488 );
489 }
490 }
491 #[cfg(target_os = "macos")]
494 #[test]
495 fn macos_sandbox_denies_file_reads_writes_network_and_processes() {
496 let file = std::env::temp_dir().join(format!("document-graphics-{}", std::process::id()));
497 std::fs::write(&file, b"private").unwrap();
498 let output = Command::new(std::env::current_exe().unwrap())
499 .args(["worker::tests::sandbox_probe", "--exact", "--ignored"])
500 .env("DOCUMENT_GRAPHICS_SANDBOX_PROBE", &file)
501 .output()
502 .unwrap();
503 let contents = std::fs::read(&file).unwrap();
504 std::fs::remove_file(&file).unwrap();
505 let report = String::from_utf8_lossy(&output.stdout);
506 assert!(
507 output.status.success() && report.contains("1 passed"),
508 "{report}{}",
509 String::from_utf8_lossy(&output.stderr)
510 );
511 assert_eq!(contents, b"private");
512 }
513 #[cfg(target_os = "macos")]
514 #[test]
515 #[ignore = "run in a child process by the macOS sandbox test"]
516 fn sandbox_probe() {
517 let Some(file) = std::env::var_os("DOCUMENT_GRAPHICS_SANDBOX_PROBE") else {
519 return;
520 };
521 let spawn = || Command::new("/usr/bin/true").status();
523 assert!(std::fs::read(&file).is_ok() && spawn().is_ok());
524 assert!(std::net::TcpListener::bind("127.0.0.1:0").is_ok());
525 restrict_worker().unwrap();
526 assert!(std::fs::read(&file).is_err(), "read a file");
527 assert!(std::fs::write(&file, b"changed").is_err(), "wrote a file");
528 assert!(
529 std::net::TcpListener::bind("127.0.0.1:0").is_err(),
530 "opened a socket"
531 );
532 assert!(spawn().is_err(), "started a process");
533 }
534}