penpot/backend/src/app/util/shell.clj
Andrey Antukh 9e52bb40d0
Add process-level resource limits to font processing tools (#10274)
*  Add font processing resource limits via prlimit

Font processing tools (fontforge, sfnt2woff, woff2sfnt, woff2_decompress)
were invoked via clojure.java.shell/sh with no timeouts or resource limits.
This adds process-level resource limits using prlimit(1) and the shell/exec!
infrastructure from the ImageMagick hardening work.

shell/exec! changes:
- Add :prlimit parameter that prepends prlimit(1) to the command
- :prlimit takes {:mem <MiB> :cpu <seconds>} for address space and CPU time
  limits, enforced by the kernel's RLIMIT subsystem
- prlimit-cmd builds the prlimit command prefix (private helper)

Font processing changes:
- Replace all clojure.java.shell/sh calls with shell/exec! via exec-font!
- exec-font! applies font-prlimit (512 MiB, 30s CPU, 60s wall-clock)
- All 5 conversion functions (ttf->otf, otf->ttf, ttf-or-otf->woff,
  woff->sfnt, woff2->sfnt) use try/finally for explicit temp file cleanup
- Remove clojure.java.shell require from media.clj

Tests:
- Add exec-prlimit-normal, exec-prlimit-cpu, exec-prlimit-memory tests

Closes #10234

Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>

*  Make font processing resource limits configurable

Replace hardcoded font-prlimit map and wall-clock timeout with
config-driven values under the PENPOT_FONT_PROCESS_* namespace.
The prlimit implementation detail is not exposed in config keys.

Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>

---------

Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>
2026-06-19 11:30:48 +02:00

105 lines
3.6 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.util.shell
"A penpot specific, modern api for executing external (shell)
subprocesses"
(:require
[app.common.exceptions :as ex]
[app.worker :as-alias wrk]
[datoteka.io :as io]
[promesa.exec :as px])
(:import
java.io.InputStream
java.io.OutputStream
java.util.concurrent.TimeUnit
java.util.List
org.apache.commons.io.IOUtils))
(set! *warn-on-reflection* true)
(defn- prlimit-cmd
"Build a prlimit command prefix from a resource limits map.
Returns nil if limits is nil/empty."
[limits]
(when (seq limits)
(let [prefix (cond-> ["prlimit"]
(:mem limits)
(conj (str "--as=" (* (long (:mem limits)) 1024 1024)))
(:cpu limits)
(conj (str "--cpu=" (long (:cpu limits)))))]
(conj prefix "--"))))
(defn- read-as-bytes
[in]
(with-open [^InputStream input (io/input-stream in)]
(io/read input)))
(defn- read-as-string
([in] (read-as-string in "UTF-8"))
([in enc]
(IOUtils/toString ^InputStream in ^String enc)))
(defn- read-with-enc
[stream enc]
(if (= enc :bytes)
(read-as-bytes stream)
(read-as-string stream enc)))
(defn- set-env
[penv k v]
(.put ^java.util.Map penv
^String k
^String v)
penv)
(defn exec!
[system & {:keys [cmd in out-enc in-enc env prlimit timeout]
:or {out-enc "UTF-8"
in-enc "UTF-8"}}]
(assert (vector? cmd) "a command parameter should be a vector")
(assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd
(seq prlimit)
(into (prlimit-cmd prlimit)))
builder (ProcessBuilder. ^List full-cmd)
env-map (.environment ^ProcessBuilder builder)
_ (reduce-kv set-env env-map env)
process (.start builder)]
(if in
(px/run! executor
(fn []
(with-open [^OutputStream stdin (.getOutputStream ^Process process)]
(io/write stdin in :encoding in-enc))))
(io/close (.getOutputStream ^Process process)))
(with-open [stdout (.getInputStream ^Process process)
stderr (.getErrorStream ^Process process)]
(let [out (px/submit! executor (fn [] (try (read-with-enc stdout out-enc)
(catch java.io.IOException _ ""))))
err (px/submit! executor (fn [] (try (read-as-string stderr)
(catch java.io.IOException _ ""))))
ext (if timeout
(let [completed (.waitFor ^Process process (long timeout) TimeUnit/SECONDS)]
(if completed
(.exitValue ^Process process)
(do
(.destroyForcibly ^Process process)
(ex/raise :type :internal
:code :process-timeout
:hint (str "process timed out after " timeout " seconds")
:cmd cmd
:timeout timeout))))
(.waitFor ^Process process))]
{:exit ext
:out @out
:err @err}))))