diff mrjunejune/latex_renderer.c @ 244:b8aa08503378

[tools] Add sandboxed online LaTeX editor Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 16:56:25 -0700
parents
children 70f2a3dafc1c
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/latex_renderer.c	Mon Aug 03 16:56:25 2026 -0700
@@ -0,0 +1,718 @@
+#include "mrjunejune/latex_renderer.h"
+
+#ifdef __linux__
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/audit.h>
+#include <linux/filter.h>
+#include <linux/landlock.h>
+#include <linux/seccomp.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/resource.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#ifndef LANDLOCK_ACCESS_FS_REFER
+#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13)
+#endif
+#ifndef LANDLOCK_ACCESS_FS_TRUNCATE
+#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14)
+#endif
+
+#if defined(__x86_64__)
+#define LATEX_AUDIT_ARCH AUDIT_ARCH_X86_64
+#elif defined(__aarch64__)
+#define LATEX_AUDIT_ARCH AUDIT_ARCH_AARCH64
+#else
+#define LATEX_AUDIT_ARCH 0
+#endif
+
+#define LATEX_DIAGNOSTICS_MAX_BYTES (16 * 1024)
+#define LATEX_CPU_TIMEOUT_SECONDS 5
+#define LATEX_WALL_TIMEOUT_SECONDS 8
+
+static char *duplicate_message(const char *message)
+{
+  size_t length = strlen(message);
+  char *copy = malloc(length + 1);
+  if (copy)
+    memcpy(copy, message, length + 1);
+  return copy;
+}
+
+static Latex_Render_Result result_with_message(
+    Latex_Render_Status status,
+    const char *message)
+{
+  return (Latex_Render_Result){
+    .status = status,
+    .diagnostics = duplicate_message(message),
+  };
+}
+
+static bool write_all(int fd, const uint8_t *data, size_t size)
+{
+  while (size > 0)
+  {
+    ssize_t written = write(fd, data, size);
+    if (written < 0)
+    {
+      if (errno == EINTR)
+        continue;
+      return false;
+    }
+    data += written;
+    size -= (size_t)written;
+  }
+  return true;
+}
+
+static int remove_tree(const char *path, unsigned depth)
+{
+  if (depth > 16)
+    return -1;
+
+  struct stat status;
+  if (lstat(path, &status) != 0)
+    return errno == ENOENT ? 0 : -1;
+  if (!S_ISDIR(status.st_mode))
+    return unlink(path);
+
+  DIR *directory = opendir(path);
+  if (!directory)
+    return -1;
+
+  int result = 0;
+  struct dirent *entry;
+  while ((entry = readdir(directory)) != NULL)
+  {
+    if (strcmp(entry->d_name, ".") == 0 ||
+        strcmp(entry->d_name, "..") == 0)
+      continue;
+
+    char child[1024];
+    int length = snprintf(
+        child,
+        sizeof(child),
+        "%s/%s",
+        path,
+        entry->d_name);
+    if (length < 0 ||
+        (size_t)length >= sizeof(child) ||
+        remove_tree(child, depth + 1) != 0)
+    {
+      result = -1;
+      break;
+    }
+  }
+  closedir(directory);
+  return result == 0 ? rmdir(path) : result;
+}
+
+static bool set_limit(int resource, rlim_t value)
+{
+  struct rlimit limit = {
+    .rlim_cur = value,
+    .rlim_max = value,
+  };
+  return setrlimit(resource, &limit) == 0;
+}
+
+static bool add_landlock_path_rule(
+    int ruleset_fd,
+    const char *path,
+    uint64_t access,
+    bool required)
+{
+  int path_fd = open(path, O_PATH | O_CLOEXEC);
+  if (path_fd < 0)
+    return !required && errno == ENOENT;
+
+  struct stat status;
+  bool ok = fstat(path_fd, &status) == 0;
+  if (ok && !S_ISDIR(status.st_mode))
+  {
+    access &= LANDLOCK_ACCESS_FS_EXECUTE |
+        LANDLOCK_ACCESS_FS_READ_FILE |
+        LANDLOCK_ACCESS_FS_WRITE_FILE;
+  }
+
+  struct landlock_path_beneath_attr rule = {
+    .allowed_access = access,
+    .parent_fd = path_fd,
+  };
+  if (ok)
+    ok = syscall(
+        SYS_landlock_add_rule,
+        ruleset_fd,
+        LANDLOCK_RULE_PATH_BENEATH,
+        &rule,
+        0) == 0;
+  close(path_fd);
+  return ok;
+}
+
+static bool apply_filesystem_sandbox(const char *job_directory)
+{
+  int abi = syscall(
+      SYS_landlock_create_ruleset,
+      NULL,
+      0,
+      LANDLOCK_CREATE_RULESET_VERSION);
+  if (abi < 1)
+    return false;
+
+  uint64_t read_access =
+      LANDLOCK_ACCESS_FS_EXECUTE |
+      LANDLOCK_ACCESS_FS_READ_FILE |
+      LANDLOCK_ACCESS_FS_READ_DIR;
+  uint64_t write_access =
+      LANDLOCK_ACCESS_FS_WRITE_FILE |
+      LANDLOCK_ACCESS_FS_REMOVE_DIR |
+      LANDLOCK_ACCESS_FS_REMOVE_FILE |
+      LANDLOCK_ACCESS_FS_MAKE_CHAR |
+      LANDLOCK_ACCESS_FS_MAKE_DIR |
+      LANDLOCK_ACCESS_FS_MAKE_REG |
+      LANDLOCK_ACCESS_FS_MAKE_SOCK |
+      LANDLOCK_ACCESS_FS_MAKE_FIFO |
+      LANDLOCK_ACCESS_FS_MAKE_BLOCK |
+      LANDLOCK_ACCESS_FS_MAKE_SYM;
+  if (abi >= 2)
+    write_access |= LANDLOCK_ACCESS_FS_REFER;
+  if (abi >= 3)
+    write_access |= LANDLOCK_ACCESS_FS_TRUNCATE;
+
+  struct landlock_ruleset_attr ruleset = {
+    .handled_access_fs = read_access | write_access,
+  };
+  int ruleset_fd = syscall(
+      SYS_landlock_create_ruleset,
+      &ruleset,
+      sizeof(ruleset),
+      0);
+  if (ruleset_fd < 0)
+    return false;
+
+  const char *read_only_directories[] = {
+    "/usr",
+    "/lib",
+    "/lib64",
+    "/bin",
+    "/var/lib/texmf",
+    "/etc/texmf",
+    "/etc/fonts",
+  };
+  bool ok = true;
+  for (size_t i = 0;
+       ok && i < sizeof(read_only_directories) / sizeof(read_only_directories[0]);
+       i++)
+  {
+    ok = add_landlock_path_rule(
+        ruleset_fd,
+        read_only_directories[i],
+        read_access,
+        false);
+  }
+  if (ok)
+    ok = add_landlock_path_rule(
+        ruleset_fd,
+        "/etc/ld.so.cache",
+        LANDLOCK_ACCESS_FS_READ_FILE,
+        false);
+  if (ok)
+    ok = add_landlock_path_rule(
+        ruleset_fd,
+        "/dev/null",
+        LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE,
+        true);
+  if (ok)
+    ok = add_landlock_path_rule(
+        ruleset_fd,
+        "/dev/urandom",
+        LANDLOCK_ACCESS_FS_READ_FILE,
+        false);
+  if (ok)
+    ok = add_landlock_path_rule(
+        ruleset_fd,
+        job_directory,
+        read_access | write_access,
+        true);
+  if (ok)
+    ok = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0;
+  if (ok)
+    ok = syscall(
+        SYS_landlock_restrict_self,
+        ruleset_fd,
+        0) == 0;
+  close(ruleset_fd);
+  return ok;
+}
+
+static bool apply_network_sandbox(void)
+{
+  if (LATEX_AUDIT_ARCH == 0)
+    return false;
+
+  struct sock_filter filters[64];
+  size_t count = 0;
+#define ADD_FILTER(value) filters[count++] = (struct sock_filter)value
+#define DENY_SYSCALL(number) \
+  do { \
+    ADD_FILTER(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, number, 0, 1)); \
+    ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM)); \
+  } while (0)
+
+  ADD_FILTER(BPF_STMT(
+      BPF_LD | BPF_W | BPF_ABS,
+      offsetof(struct seccomp_data, arch)));
+  ADD_FILTER(BPF_JUMP(
+      BPF_JMP | BPF_JEQ | BPF_K,
+      LATEX_AUDIT_ARCH,
+      1,
+      0));
+  ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS));
+  ADD_FILTER(BPF_STMT(
+      BPF_LD | BPF_W | BPF_ABS,
+      offsetof(struct seccomp_data, nr)));
+  DENY_SYSCALL(__NR_socket);
+  DENY_SYSCALL(__NR_socketpair);
+  DENY_SYSCALL(__NR_connect);
+  DENY_SYSCALL(__NR_accept);
+  DENY_SYSCALL(__NR_accept4);
+  DENY_SYSCALL(__NR_bind);
+  DENY_SYSCALL(__NR_listen);
+  DENY_SYSCALL(__NR_sendto);
+  DENY_SYSCALL(__NR_recvfrom);
+  DENY_SYSCALL(__NR_sendmsg);
+  DENY_SYSCALL(__NR_recvmsg);
+  ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
+
+  struct sock_fprog program = {
+    .len = (unsigned short)count,
+    .filter = filters,
+  };
+#undef DENY_SYSCALL
+#undef ADD_FILTER
+  return prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) == 0;
+}
+
+static void configure_child_environment(const char *job_directory)
+{
+  clearenv();
+  setenv("HOME", job_directory, 1);
+  setenv("TMPDIR", job_directory, 1);
+  setenv("TEXMFVAR", job_directory, 1);
+  setenv("TEXMFCONFIG", job_directory, 1);
+  setenv("openin_any", "p", 1);
+  setenv("openout_any", "p", 1);
+  setenv("shell_escape", "0", 1);
+  setenv("SOURCE_DATE_EPOCH", "0", 1);
+  setenv("LANG", "C.UTF-8", 1);
+}
+
+static void close_inherited_descriptors(void)
+{
+#ifdef SYS_close_range
+  if (syscall(SYS_close_range, 3U, UINT_MAX, 0) == 0)
+    return;
+#endif
+  struct rlimit limit;
+  rlim_t maximum = 65536;
+  if (getrlimit(RLIMIT_NOFILE, &limit) == 0 &&
+      limit.rlim_cur != RLIM_INFINITY &&
+      limit.rlim_cur < maximum)
+    maximum = limit.rlim_cur;
+  for (int fd = 3; fd < (int)maximum; fd++)
+    close(fd);
+}
+
+static void run_compiler_child(
+    const char *compiler,
+    const char *job_directory,
+    int output_fd)
+{
+  if (dup2(output_fd, STDOUT_FILENO) < 0 ||
+      dup2(output_fd, STDERR_FILENO) < 0)
+    _exit(120);
+  if (output_fd != STDOUT_FILENO && output_fd != STDERR_FILENO)
+    close(output_fd);
+  close_inherited_descriptors();
+  if (setpgid(0, 0) != 0 ||
+      prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 ||
+      getppid() == 1)
+    _exit(119);
+  if (chdir(job_directory) != 0)
+    _exit(121);
+
+  configure_child_environment(job_directory);
+  if (!set_limit(RLIMIT_CPU, LATEX_CPU_TIMEOUT_SECONDS) ||
+      !set_limit(RLIMIT_AS, 768 * 1024 * 1024) ||
+      !set_limit(RLIMIT_FSIZE, LATEX_PDF_MAX_BYTES) ||
+      !set_limit(RLIMIT_NOFILE, 64) ||
+      !set_limit(RLIMIT_NPROC, 1))
+    _exit(122);
+  if (!apply_filesystem_sandbox(job_directory) ||
+      !apply_network_sandbox())
+    _exit(123);
+
+  char *const arguments[] = {
+    (char *)compiler,
+    "-interaction=nonstopmode",
+    "-halt-on-error",
+    "-no-shell-escape",
+    "-file-line-error",
+    "-output-directory=.",
+    "document.tex",
+    NULL,
+  };
+  execv(compiler, arguments);
+  _exit(errno == ENOENT ? 127 : 126);
+}
+
+static double elapsed_seconds(
+    const struct timespec *start,
+    const struct timespec *now)
+{
+  return (double)(now->tv_sec - start->tv_sec) +
+      (double)(now->tv_nsec - start->tv_nsec) / 1000000000.0;
+}
+
+static int wait_for_compiler(
+    pid_t child,
+    int output_fd,
+    char *diagnostics,
+    size_t diagnostics_size,
+    bool *timed_out)
+{
+  int flags = fcntl(output_fd, F_GETFL, 0);
+  if (flags >= 0)
+    fcntl(output_fd, F_SETFL, flags | O_NONBLOCK);
+
+  size_t used = 0;
+  int child_status = 0;
+  bool child_done = false;
+  struct timespec start;
+  clock_gettime(CLOCK_MONOTONIC, &start);
+
+  while (!child_done)
+  {
+    char buffer[2048];
+    ssize_t count;
+    while ((count = read(output_fd, buffer, sizeof(buffer))) > 0)
+    {
+      size_t available = diagnostics_size - used - 1;
+      size_t copy_size = (size_t)count < available ? (size_t)count : available;
+      if (copy_size > 0)
+      {
+        memcpy(diagnostics + used, buffer, copy_size);
+        used += copy_size;
+      }
+    }
+
+    pid_t waited = waitpid(child, &child_status, WNOHANG);
+    if (waited == child)
+    {
+      child_done = true;
+      break;
+    }
+    if (waited < 0 && errno != EINTR)
+      break;
+
+    struct timespec now;
+    clock_gettime(CLOCK_MONOTONIC, &now);
+    if (elapsed_seconds(&start, &now) >= LATEX_WALL_TIMEOUT_SECONDS)
+    {
+      *timed_out = true;
+      kill(-child, SIGKILL);
+      while (waitpid(child, &child_status, 0) < 0 && errno == EINTR)
+      {
+      }
+      child_done = true;
+      break;
+    }
+
+    struct pollfd poll_fd = {
+      .fd = output_fd,
+      .events = POLLIN,
+    };
+    poll(&poll_fd, 1, 50);
+  }
+
+  char buffer[2048];
+  ssize_t count;
+  while ((count = read(output_fd, buffer, sizeof(buffer))) > 0)
+  {
+    size_t available = diagnostics_size - used - 1;
+    size_t copy_size = (size_t)count < available ? (size_t)count : available;
+    if (copy_size > 0)
+    {
+      memcpy(diagnostics + used, buffer, copy_size);
+      used += copy_size;
+    }
+  }
+  diagnostics[used] = '\0';
+  kill(-child, SIGKILL);
+  return child_status;
+}
+
+static bool read_pdf(
+    const char *path,
+    uint8_t **data,
+    size_t *size)
+{
+  int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+  if (fd < 0)
+    return false;
+
+  struct stat status;
+  bool ok = fstat(fd, &status) == 0 &&
+      S_ISREG(status.st_mode) &&
+      status.st_size > 4 &&
+      status.st_size <= LATEX_PDF_MAX_BYTES;
+  uint8_t *content = NULL;
+  if (ok)
+  {
+    content = malloc((size_t)status.st_size);
+    ok = content != NULL;
+  }
+  size_t offset = 0;
+  while (ok && offset < (size_t)status.st_size)
+  {
+    ssize_t count = read(
+        fd,
+        content + offset,
+        (size_t)status.st_size - offset);
+    if (count < 0 && errno == EINTR)
+      continue;
+    if (count <= 0)
+    {
+      ok = false;
+      break;
+    }
+    offset += (size_t)count;
+  }
+  close(fd);
+
+  if (!ok ||
+      memcmp(content, "%PDF-", 5) != 0)
+  {
+    free(content);
+    return false;
+  }
+  *data = content;
+  *size = offset;
+  return true;
+}
+
+Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size)
+{
+  if (!source || source_size == 0)
+    return result_with_message(
+        LATEX_RENDER_INVALID_INPUT,
+        "LaTeX source is required.");
+  if (source_size > LATEX_SOURCE_MAX_BYTES)
+    return result_with_message(
+        LATEX_RENDER_INVALID_INPUT,
+        "LaTeX source exceeds the 64 KiB limit.");
+  if (memchr(source, '\0', source_size))
+    return result_with_message(
+        LATEX_RENDER_INVALID_INPUT,
+        "LaTeX source cannot contain NUL bytes.");
+
+  const char *compiler = "/usr/bin/pdflatex";
+  if (access(compiler, X_OK) != 0)
+    return result_with_message(
+        LATEX_RENDER_COMPILER_UNAVAILABLE,
+        "The server LaTeX compiler is unavailable.");
+
+  char job_directory[] = "/tmp/mrjunejune-latex-XXXXXX";
+  if (!mkdtemp(job_directory))
+    return result_with_message(
+        LATEX_RENDER_INTERNAL_ERROR,
+        "Unable to create the LaTeX workspace.");
+
+  char source_path[512];
+  snprintf(
+      source_path,
+      sizeof(source_path),
+      "%s/document.tex",
+      job_directory);
+  int source_fd = open(
+      source_path,
+      O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+      0600);
+  if (source_fd < 0 || !write_all(source_fd, source, source_size))
+  {
+    if (source_fd >= 0)
+      close(source_fd);
+    remove_tree(job_directory, 0);
+    return result_with_message(
+        LATEX_RENDER_INTERNAL_ERROR,
+        "Unable to write the LaTeX source.");
+  }
+  close(source_fd);
+
+  int output_pipe[2];
+  if (pipe2(output_pipe, O_CLOEXEC) != 0)
+  {
+    remove_tree(job_directory, 0);
+    return result_with_message(
+        LATEX_RENDER_INTERNAL_ERROR,
+        "Unable to capture compiler output.");
+  }
+
+  pid_t child = fork();
+  if (child < 0)
+  {
+    close(output_pipe[0]);
+    close(output_pipe[1]);
+    remove_tree(job_directory, 0);
+    return result_with_message(
+        LATEX_RENDER_INTERNAL_ERROR,
+        "Unable to start the LaTeX compiler.");
+  }
+  if (child == 0)
+  {
+    close(output_pipe[0]);
+    run_compiler_child(compiler, job_directory, output_pipe[1]);
+  }
+  setpgid(child, child);
+
+  close(output_pipe[1]);
+  char *diagnostics = calloc(1, LATEX_DIAGNOSTICS_MAX_BYTES + 1);
+  if (!diagnostics)
+  {
+    kill(-child, SIGKILL);
+    waitpid(child, NULL, 0);
+    close(output_pipe[0]);
+    remove_tree(job_directory, 0);
+    return result_with_message(
+        LATEX_RENDER_INTERNAL_ERROR,
+        "Unable to allocate compiler diagnostics.");
+  }
+
+  bool timed_out = false;
+  int child_status = wait_for_compiler(
+      child,
+      output_pipe[0],
+      diagnostics,
+      LATEX_DIAGNOSTICS_MAX_BYTES + 1,
+      &timed_out);
+  close(output_pipe[0]);
+
+  Latex_Render_Result result = {
+    .status = LATEX_RENDER_INTERNAL_ERROR,
+    .diagnostics = diagnostics,
+  };
+  if (timed_out)
+  {
+    result.status = LATEX_RENDER_TIMEOUT;
+    free(result.diagnostics);
+    result.diagnostics = duplicate_message(
+        "Compilation exceeded the 8 second limit.");
+  }
+  else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 127)
+  {
+    result.status = LATEX_RENDER_COMPILER_UNAVAILABLE;
+  }
+  else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 123)
+  {
+    result.status = LATEX_RENDER_SANDBOX_ERROR;
+    free(result.diagnostics);
+    result.diagnostics = duplicate_message(
+        "The server could not apply the LaTeX security sandbox.");
+  }
+  else if (WIFSIGNALED(child_status) &&
+           (WTERMSIG(child_status) == SIGXCPU ||
+            WTERMSIG(child_status) == SIGKILL))
+  {
+    result.status = LATEX_RENDER_TIMEOUT;
+    free(result.diagnostics);
+    result.diagnostics = duplicate_message(
+        "Compilation exceeded its CPU or memory limit.");
+  }
+  else if (!WIFEXITED(child_status) || WEXITSTATUS(child_status) != 0)
+  {
+    result.status = LATEX_RENDER_COMPILE_ERROR;
+    if (!result.diagnostics[0])
+    {
+      free(result.diagnostics);
+      result.diagnostics = duplicate_message("LaTeX compilation failed.");
+    }
+  }
+  else
+  {
+    char pdf_path[512];
+    snprintf(pdf_path, sizeof(pdf_path), "%s/document.pdf", job_directory);
+    if (read_pdf(pdf_path, &result.pdf_data, &result.pdf_size))
+    {
+      result.status = LATEX_RENDER_OK;
+      free(result.diagnostics);
+      result.diagnostics = NULL;
+    }
+    else
+    {
+      result.status = LATEX_RENDER_INTERNAL_ERROR;
+      free(result.diagnostics);
+      result.diagnostics = duplicate_message(
+          "The compiler did not produce a valid PDF.");
+    }
+  }
+
+  remove_tree(job_directory, 0);
+  return result;
+}
+
+void Latex_Render_Result_Destroy(Latex_Render_Result *result)
+{
+  if (!result)
+    return;
+  free(result->pdf_data);
+  free(result->diagnostics);
+  memset(result, 0, sizeof(*result));
+}
+
+#else
+
+#include <stdlib.h>
+#include <string.h>
+
+Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size)
+{
+  (void)source;
+  (void)source_size;
+  const char *message =
+      "The LaTeX security sandbox is only available on Linux.";
+  Latex_Render_Result result = {
+    .status = LATEX_RENDER_SANDBOX_ERROR,
+    .diagnostics = malloc(strlen(message) + 1),
+  };
+  if (result.diagnostics)
+    strcpy(result.diagnostics, message);
+  return result;
+}
+
+void Latex_Render_Result_Destroy(Latex_Render_Result *result)
+{
+  if (!result)
+    return;
+  free(result->pdf_data);
+  free(result->diagnostics);
+  memset(result, 0, sizeof(*result));
+}
+
+#endif