[Security] Replace diskcache to eliminate pickle deserialization (#44549)
Signed-off-by: Russell Bryant <rbryant@redhat.com> Co-authored-by: Claude <noreply@anthropic.com>
Russell Bryant committed
Jul 14, 2026 at 23:29 UTC
96d2ceda4bb54d5601db7b464070860856c4f0b5
7 files changed
+162
-19
requirements/common.txt
-2
@@ -23,8 +23,6 @@ tiktoken >= 0.6.0 # Required for DBRX tokenizer
23
lm-format-enforcer == 0.11.3
24
llguidance >= 1.7.0, < 1.8.0; platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "s390x"
25
outlines_core == 0.2.14
26
-# required for outlines backend disk cache
27
-diskcache == 5.6.3
26
lark == 1.2.2
27
xgrammar >= 0.2.1, < 1.0.0; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
28
typing_extensions >= 4.10
requirements/test/cpu.txt
-2
@@ -179,8 +179,6 @@ dill==0.3.8
179
# evaluate
180
# lm-eval
181
# multiprocess
182
-diskcache==5.6.3
183
- # via -r requirements/test/../common.txt
182
distlib==0.3.9
183
# via virtualenv
184
distro==1.9.0
requirements/test/cuda.txt
-4
@@ -194,10 +194,6 @@ dill==0.3.8
194
# evaluate
195
# lm-eval
196
# multiprocess
197
-diskcache==5.6.3
198
- # via
199
- # -c requirements/common.txt
200
- # -r requirements/test/../common.txt
197
distlib==0.3.9
198
# via virtualenv
199
distro==1.9.0
requirements/test/rocm.txt
-4
@@ -187,10 +187,6 @@ dill==0.3.8
187
# evaluate
188
# lm-eval
189
# multiprocess
190
-diskcache==5.6.3
191
- # via
192
- # -c requirements/common.txt
193
- # -r requirements/test/../common.txt
190
distlib==0.4.0
191
# via virtualenv
192
distro==1.9.0
requirements/test/xpu.txt
-4
@@ -128,10 +128,6 @@ dill==0.4.1
128
# evaluate
129
# lm-eval
130
# multiprocess
131
-diskcache==5.6.3
132
- # via
133
- # -c requirements/common.txt
134
- # -r requirements/test/../common.txt
131
distro==1.9.0
132
# via
133
# anthropic
tests/v1/structured_output/test_outlines_cache.py
new
+96
@@ -0,0 +1,96 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import tempfile
5
+
6
+import outlines_core as oc
7
+import pytest
8
+
9
+from vllm.v1.structured_output.utils import OutlinesDiskCache
10
+
11
+pytestmark = pytest.mark.cpu_test
12
+
13
+
14
+@pytest.fixture
15
+def vocab():
16
+ return oc.Vocabulary(3, {b"a": [0], b"b": [1], b"ab": [2]})
17
+
18
+
19
+@pytest.fixture
20
+def index(vocab):
21
+ return oc.Index("ab", vocab)
22
+
23
+
24
+@pytest.fixture
25
+def cache(tmp_path):
26
+ return OutlinesDiskCache(str(tmp_path))
27
+
28
+
29
+class TestOutlinesDiskCache:
30
+ def test_store_and_retrieve_index(self, cache, index):
31
+ cache["test_key"] = index
32
+ restored = cache["test_key"]
33
+ assert restored.get_initial_state() == index.get_initial_state()
34
+ assert restored.get_transitions() == index.get_transitions()
35
+ assert restored.get_final_states() == index.get_final_states()
36
+
37
+ def test_store_and_retrieve_string(self, cache):
38
+ cache.set("__version__", "0.2.14")
39
+ assert cache.get("__version__") == "0.2.14"
40
+
41
+ def test_contains(self, cache, index):
42
+ assert "missing" not in cache
43
+ cache["key"] = index
44
+ assert "key" in cache
45
+
46
+ def test_get_default(self, cache):
47
+ assert cache.get("missing", "fallback") == "fallback"
48
+ assert cache.get("missing") is None
49
+
50
+ def test_missing_key_raises(self, cache):
51
+ with pytest.raises(KeyError):
52
+ _ = cache["missing"]
53
+
54
+ def test_clear(self, cache, index):
55
+ cache["key"] = index
56
+ cache.set("__version__", "1.0")
57
+ cache.clear()
58
+ assert "key" not in cache
59
+ assert "__version__" not in cache
60
+
61
+ def test_overwrite(self, cache, vocab):
62
+ index1 = oc.Index("a", vocab)
63
+ index2 = oc.Index("b", vocab)
64
+ cache["key"] = index1
65
+ cache["key"] = index2
66
+ restored = cache["key"]
67
+ assert restored.get_transitions() == index2.get_transitions()
68
+
69
+ def test_persistence_across_instances(self, tmp_path, index):
70
+ cache1 = OutlinesDiskCache(str(tmp_path))
71
+ cache1["key"] = index
72
+
73
+ cache2 = OutlinesDiskCache(str(tmp_path))
74
+ restored = cache2["key"]
75
+ assert restored.get_transitions() == index.get_transitions()
76
+
77
+ def test_creates_directory(self):
78
+ with tempfile.TemporaryDirectory() as tmpdir:
79
+ subdir = f"{tmpdir}/nested/cache/dir"
80
+ cache = OutlinesDiskCache(subdir)
81
+ cache.set("__version__", "1.0")
82
+ assert cache.get("__version__") == "1.0"
83
+
84
+ def test_version_invalidation_flow(self, cache, index):
85
+ """Simulates the version-check logic in get_outlines_cache()."""
86
+ cache.set("__version__", "0.2.13")
87
+ cache["key"] = index
88
+
89
+ cached_version = cache.get("__version__")
90
+ new_version = "0.2.14"
91
+ if cached_version != new_version:
92
+ cache.clear()
93
+ cache.set("__version__", new_version)
94
+
95
+ assert cache.get("__version__") == "0.2.14"
96
+ assert "key" not in cache
vllm/v1/structured_output/utils.py
+66
-3
@@ -5,6 +5,7 @@ from __future__ import annotations
5
import hashlib
6
import importlib.metadata
7
import os
8
+import sqlite3
9
import tempfile
10
from collections.abc import Callable
11
from concurrent.futures import ThreadPoolExecutor, TimeoutError
@@ -214,19 +215,81 @@ def get_outlines_cache_path() -> str:
215
return os.path.join(tempdir, ".cache", "outlines")
216
217
218
+class OutlinesDiskCache:
219
+ """SQLite-backed cache for outlines_core.Index objects.
220
+
221
+ Uses outlines_core's native binary serialization (via Rust serde)
222
+ instead of pickle, eliminating arbitrary code execution risk on
223
+ deserialization.
224
+ """
225
+
226
+ _TYPE_INDEX = "I"
227
+ _TYPE_STRING = "S"
228
+
229
+ def __init__(self, path: str):
230
+ os.makedirs(path, exist_ok=True)
231
+ db_path = os.path.join(path, "outlines_cache.db")
232
+ self._db = sqlite3.connect(db_path, check_same_thread=False)
233
+ self._db.execute("PRAGMA journal_mode=WAL")
234
+ self._db.execute(
235
+ "CREATE TABLE IF NOT EXISTS cache "
236
+ "(key TEXT PRIMARY KEY, type_tag TEXT NOT NULL, value BLOB NOT NULL)"
237
+ )
238
+ self._db.commit()
239
+
240
+ def __contains__(self, key: str) -> bool:
241
+ row = self._db.execute("SELECT 1 FROM cache WHERE key=?", (key,)).fetchone()
242
+ return row is not None
243
+
244
+ def __getitem__(self, key: str):
245
+ row = self._db.execute(
246
+ "SELECT type_tag, value FROM cache WHERE key=?", (key,)
247
+ ).fetchone()
248
+ if row is None:
249
+ raise KeyError(key)
250
+ type_tag, data = row
251
+ if type_tag == self._TYPE_STRING:
252
+ return data.decode("utf-8")
253
+ return oc.Index.from_binary(data)
254
+
255
+ def __setitem__(self, key: str, value):
256
+ if isinstance(value, str):
257
+ type_tag = self._TYPE_STRING
258
+ data = value.encode("utf-8")
259
+ else:
260
+ type_tag = self._TYPE_INDEX
261
+ data = value.__reduce__()[1][0]
262
+ self._db.execute(
263
+ "INSERT OR REPLACE INTO cache (key, type_tag, value) VALUES (?, ?, ?)",
264
+ (key, type_tag, data),
265
+ )
266
+ self._db.commit()
267
+
268
+ def get(self, key: str, default=None):
269
+ try:
270
+ return self[key]
271
+ except KeyError:
272
+ return default
273
+
274
+ def set(self, key: str, value):
275
+ self[key] = value
276
+
277
+ def clear(self):
278
+ self._db.execute("DELETE FROM cache")
279
+ self._db.commit()
280
+
281
+
282
def get_outlines_cache():
283
"""Get the Cache instance to be used for index caching"""
284
285
cache_dir = get_outlines_cache_path()
286
if envs.VLLM_V1_USE_OUTLINES_CACHE:
222
- from diskcache import Cache
223
-
287
logger.warning(
288
"Enabling outlines cache. This is an unbounded on-disk "
289
"cache. It may consume a lot of disk space and should "
290
"not be used with untrusted clients."
291
)
229
- cache = Cache(cache_dir, eviction_policy="none", cull_limit=0)
292
+ cache = OutlinesDiskCache(cache_dir)
293
outlines_version = importlib.metadata.version("outlines_core")
294
295
cached_version = cache.get("__version__", None)