milan1 commited on
Commit
66e41bf
·
verified ·
1 Parent(s): b0c38d1

Deploy 1bd20861 from GitHub Actions

Browse files
app/api/v1/upload.py CHANGED
@@ -119,6 +119,32 @@ async def upload_document(
119
  pass
120
  raise
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  # Internal storage reference — resolved only against UPLOAD_DIR. Storing a
123
  # bare key rather than an absolute path keeps the row portable across
124
  # containers and gives the SSRF guard a value it can safely confine.
 
119
  pass
120
  raise
121
 
122
+ # Mirror the bytes into durable storage. UPLOAD_DIR is on the container
123
+ # filesystem, which is wiped on every restart in the deployed environment,
124
+ # so the local copy alone is a cache rather than storage. A failure here is
125
+ # logged and tolerated: the upload still works for this container's
126
+ # lifetime, and rejecting it outright would be a worse outcome than storing
127
+ # it with reduced durability.
128
+ from app.services import object_storage
129
+
130
+ if object_storage.is_configured():
131
+ with open(local_path, "rb") as f:
132
+ stored = await object_storage.put_object(
133
+ local_filename, f.read(), content_type
134
+ )
135
+ if not stored:
136
+ logger.warning(
137
+ "Upload %s is on local disk only and will not survive a "
138
+ "restart: durable storage write failed.",
139
+ local_filename,
140
+ )
141
+ else:
142
+ logger.warning(
143
+ "Durable storage is not configured; upload %s will be lost when "
144
+ "the container restarts. Set SUPABASE_URL and SUPABASE_SERVICE_KEY.",
145
+ local_filename,
146
+ )
147
+
148
  # Internal storage reference — resolved only against UPLOAD_DIR. Storing a
149
  # bare key rather than an absolute path keeps the row portable across
150
  # containers and gives the SSRF guard a value it can safely confine.
app/config.py CHANGED
@@ -150,6 +150,32 @@ class Settings(BaseSettings):
150
  description="Root directory for locally stored uploads. All storage:// "
151
  "URLs resolve inside this directory and may not escape it.",
152
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  MAX_FILE_SIZE_MB: int = Field(default=50)
154
  ALLOWED_FILE_TYPES: List[str] = Field(
155
  default=[
 
150
  description="Root directory for locally stored uploads. All storage:// "
151
  "URLs resolve inside this directory and may not escape it.",
152
  )
153
+ # ── Durable object storage ────────────────────────────────────────────────
154
+ # UPLOAD_DIR lives on the container filesystem, which is ephemeral on
155
+ # HuggingFace Spaces: a restart returns it to the state baked into the
156
+ # image, i.e. empty, while the Postgres rows describing those files
157
+ # survive. Documents then appear healthy until something needs the
158
+ # original bytes, at which point extraction fails with "Stored file no
159
+ # longer exists". Setting these moves the bytes somewhere that outlives
160
+ # the container; leaving them unset keeps the previous local-disk
161
+ # behaviour so local development needs no cloud credentials.
162
+ SUPABASE_URL: str = Field(
163
+ default="",
164
+ description="Supabase project URL, e.g. https://xxxx.supabase.co. "
165
+ "Enables durable upload storage when set together with "
166
+ "SUPABASE_SERVICE_KEY.",
167
+ )
168
+ SUPABASE_SERVICE_KEY: str = Field(
169
+ default="",
170
+ description="Supabase service role key. The uploads bucket is private "
171
+ "and only this backend reads it, so the anon key is not sufficient.",
172
+ )
173
+ SUPABASE_STORAGE_BUCKET: str = Field(
174
+ default="documents",
175
+ description="Bucket name for uploaded documents. Create it as a "
176
+ "private bucket in the Supabase dashboard.",
177
+ )
178
+
179
  MAX_FILE_SIZE_MB: int = Field(default=50)
180
  ALLOWED_FILE_TYPES: List[str] = Field(
181
  default=[
app/core/url_guard.py CHANGED
@@ -239,3 +239,30 @@ def resolve_storage_path(file_url: str) -> str:
239
  def is_internal_storage_url(file_url: str) -> bool:
240
  """True if this URL refers to a file this backend stored itself."""
241
  return urlparse(file_url).scheme in (STORAGE_SCHEME, "file")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  def is_internal_storage_url(file_url: str) -> bool:
240
  """True if this URL refers to a file this backend stored itself."""
241
  return urlparse(file_url).scheme in (STORAGE_SCHEME, "file")
242
+
243
+
244
+ def storage_key_from_url(file_url: str) -> str:
245
+ """
246
+ Extract the bare object key from an internal storage URL.
247
+
248
+ This is the name used both for the file inside UPLOAD_DIR and for the
249
+ object in durable storage, so the two stay addressable by the same value.
250
+ Legacy file:// rows hold an absolute path; only its basename is meaningful
251
+ as a key.
252
+ """
253
+ parsed = urlparse(file_url)
254
+
255
+ if parsed.scheme == STORAGE_SCHEME:
256
+ raw = f"{parsed.netloc}{parsed.path}"
257
+ elif parsed.scheme == "file":
258
+ raw = parsed.path
259
+ if os.name == "nt" and raw.startswith("/"):
260
+ raw = raw[1:]
261
+ else:
262
+ raise UnsafeURLError(f"Not an internal storage URL: {parsed.scheme}")
263
+
264
+ key = os.path.basename(raw)
265
+ if not key:
266
+ raise UnsafeURLError("Storage URL is missing a key")
267
+
268
+ return key
app/services/extractors.py CHANGED
@@ -19,14 +19,61 @@ import httpx
19
 
20
  from app.config import settings
21
  from app.core.url_guard import (
 
22
  fetch_remote_file,
23
  is_internal_storage_url,
24
  resolve_storage_path,
 
25
  )
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  def _read_capped(path: str, max_bytes: int) -> bytes:
31
  """Read a local file, refusing anything over the size ceiling."""
32
  if os.path.getsize(path) > max_bytes:
@@ -311,8 +358,14 @@ async def download_and_extract(file_url: str, file_type: str) -> ExtractedDocume
311
 
312
  if is_internal_storage_url(file_url):
313
  # Path is confined to UPLOAD_DIR by the guard; traversal is rejected.
314
- local_path = await asyncio.to_thread(resolve_storage_path, file_url)
315
- file_bytes = await asyncio.to_thread(_read_capped, local_path, max_bytes)
 
 
 
 
 
 
316
  else:
317
  # User-supplied URL: https only, public addresses only, size-capped.
318
  file_bytes = await fetch_remote_file(file_url, max_bytes=max_bytes)
 
19
 
20
  from app.config import settings
21
  from app.core.url_guard import (
22
+ UnsafeURLError,
23
  fetch_remote_file,
24
  is_internal_storage_url,
25
  resolve_storage_path,
26
+ storage_key_from_url,
27
  )
28
 
29
  logger = logging.getLogger(__name__)
30
 
31
 
32
+ async def _restore_from_durable_storage(file_url: str, max_bytes: int) -> bytes:
33
+ """
34
+ Recover a document whose local copy has been lost, and re-cache it.
35
+
36
+ Raises UnsafeURLError with the original message when no durable copy
37
+ exists, so callers upstream see the same failure they always did.
38
+ """
39
+ from app.services import object_storage
40
+
41
+ key = storage_key_from_url(file_url)
42
+
43
+ if not object_storage.is_configured():
44
+ raise UnsafeURLError(
45
+ "Stored file no longer exists, and durable storage is not "
46
+ "configured. Set SUPABASE_URL and SUPABASE_SERVICE_KEY so uploads "
47
+ "survive a container restart."
48
+ )
49
+
50
+ data = await object_storage.get_object(key)
51
+ if data is None:
52
+ raise UnsafeURLError("Stored file no longer exists")
53
+
54
+ if len(data) > max_bytes:
55
+ raise UnsafeURLError("Stored file exceeds the maximum allowed size")
56
+
57
+ logger.info("Restored %s from durable storage after local copy was lost", key)
58
+
59
+ # Repopulate the local cache; failure here is not fatal because the bytes
60
+ # needed for this request are already in hand.
61
+ try:
62
+ os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
63
+ await asyncio.to_thread(
64
+ _write_bytes, os.path.join(settings.UPLOAD_DIR, key), data
65
+ )
66
+ except OSError as exc:
67
+ logger.warning("Could not re-cache %s locally: %s", key, exc)
68
+
69
+ return data
70
+
71
+
72
+ def _write_bytes(path: str, data: bytes) -> None:
73
+ with open(path, "wb") as f:
74
+ f.write(data)
75
+
76
+
77
  def _read_capped(path: str, max_bytes: int) -> bytes:
78
  """Read a local file, refusing anything over the size ceiling."""
79
  if os.path.getsize(path) > max_bytes:
 
358
 
359
  if is_internal_storage_url(file_url):
360
  # Path is confined to UPLOAD_DIR by the guard; traversal is rejected.
361
+ try:
362
+ local_path = await asyncio.to_thread(resolve_storage_path, file_url)
363
+ file_bytes = await asyncio.to_thread(_read_capped, local_path, max_bytes)
364
+ except UnsafeURLError:
365
+ # The local copy is gone — the usual cause is a container restart
366
+ # wiping UPLOAD_DIR. Fall back to the durable copy and repopulate
367
+ # the local cache so subsequent reads are local again.
368
+ file_bytes = await _restore_from_durable_storage(file_url, max_bytes)
369
  else:
370
  # User-supplied URL: https only, public addresses only, size-capped.
371
  file_bytes = await fetch_remote_file(file_url, max_bytes=max_bytes)
app/services/object_storage.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Durable object storage for uploaded documents.
3
+
4
+ Why this exists
5
+ ---------------
6
+ Uploads were written to UPLOAD_DIR on the container's own filesystem. That
7
+ filesystem is ephemeral on HuggingFace Spaces: every rebuild or restart starts
8
+ from the image, so the directory comes back empty. The database rows survive
9
+ (Postgres is external), which makes the failure especially confusing — the
10
+ document still lists, still shows its analysis, and still answers questions
11
+ from its stored embeddings, right up until something needs the original bytes
12
+ again. Then:
13
+
14
+ app.core.url_guard.UnsafeURLError: Stored file no longer exists
15
+
16
+ That is exactly what happened in production: a deploy restarted the Space,
17
+ every uploaded PDF went with it, and the next re-analysis destroyed the
18
+ document's chunks before discovering the source was gone.
19
+
20
+ The fix is to keep the bytes somewhere that outlives the container. Supabase
21
+ is already a dependency of this project for Postgres, and its Storage API is
22
+ plain HTTP, so this needs no new package — just httpx, which is already here.
23
+
24
+ Degrading gracefully
25
+ --------------------
26
+ If SUPABASE_URL / SUPABASE_SERVICE_KEY are not set, every function here
27
+ reports "not configured" and callers fall back to local disk exactly as
28
+ before. Nothing breaks; durability is simply not gained. This keeps local
29
+ development and the test suite working without cloud credentials.
30
+ """
31
+
32
+ import logging
33
+ from typing import Optional
34
+
35
+ import httpx
36
+
37
+ from app.config import settings
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Uploads are capped at MAX_FILE_SIZE_MB, so a generous per-request timeout is
42
+ # still bounded. Cold Supabase connections occasionally take a few seconds.
43
+ _TIMEOUT = httpx.Timeout(30.0, connect=10.0)
44
+
45
+
46
+ def is_configured() -> bool:
47
+ """True when a durable bucket is available to store objects in."""
48
+ return bool(settings.SUPABASE_URL and settings.SUPABASE_SERVICE_KEY)
49
+
50
+
51
+ def _object_url(key: str) -> str:
52
+ base = settings.SUPABASE_URL.rstrip("/")
53
+ bucket = settings.SUPABASE_STORAGE_BUCKET
54
+ return f"{base}/storage/v1/object/{bucket}/{key}"
55
+
56
+
57
+ def _headers() -> dict:
58
+ # The service key is used rather than the anon key: this bucket is private
59
+ # and the backend is the only thing that should read or write it.
60
+ return {
61
+ "Authorization": f"Bearer {settings.SUPABASE_SERVICE_KEY}",
62
+ "apikey": settings.SUPABASE_SERVICE_KEY,
63
+ }
64
+
65
+
66
+ async def put_object(key: str, data: bytes, content_type: str) -> bool:
67
+ """
68
+ Store bytes under `key`. Returns True on success.
69
+
70
+ Failure is logged and reported, never raised: an upload that reached local
71
+ disk is still usable for the current container's lifetime, and refusing the
72
+ whole upload because the durable copy failed would be a worse outcome than
73
+ storing it with reduced durability.
74
+ """
75
+ if not is_configured():
76
+ return False
77
+
78
+ try:
79
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
80
+ response = await client.post(
81
+ _object_url(key),
82
+ content=data,
83
+ headers={
84
+ **_headers(),
85
+ "Content-Type": content_type,
86
+ # Objects are keyed by UUID and never rewritten, so an
87
+ # existing key means a retry of the same upload.
88
+ "x-upsert": "true",
89
+ },
90
+ )
91
+
92
+ if response.status_code in (200, 201):
93
+ logger.info("Stored %s in durable storage (%d bytes)", key, len(data))
94
+ return True
95
+
96
+ logger.error(
97
+ "Durable storage rejected %s: %s %s",
98
+ key,
99
+ response.status_code,
100
+ response.text[:200],
101
+ )
102
+ return False
103
+
104
+ except Exception as exc:
105
+ logger.error("Durable storage write failed for %s: %s", key, exc)
106
+ return False
107
+
108
+
109
+ async def get_object(key: str) -> Optional[bytes]:
110
+ """
111
+ Fetch bytes for `key`, or None when unavailable.
112
+
113
+ None covers both "not configured" and "not found", because the caller does
114
+ the same thing in either case: fall back to local disk and, failing that,
115
+ report the file as missing.
116
+ """
117
+ if not is_configured():
118
+ return None
119
+
120
+ try:
121
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
122
+ response = await client.get(_object_url(key), headers=_headers())
123
+
124
+ if response.status_code == 200:
125
+ return response.content
126
+
127
+ if response.status_code != 404:
128
+ logger.error(
129
+ "Durable storage read failed for %s: %s %s",
130
+ key,
131
+ response.status_code,
132
+ response.text[:200],
133
+ )
134
+ return None
135
+
136
+ except Exception as exc:
137
+ logger.error("Durable storage read failed for %s: %s", key, exc)
138
+ return None
139
+
140
+
141
+ async def delete_object(key: str) -> None:
142
+ """Best-effort removal, so deleting a document does not leak storage."""
143
+ if not is_configured():
144
+ return
145
+
146
+ try:
147
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
148
+ await client.delete(_object_url(key), headers=_headers())
149
+ except Exception as exc:
150
+ logger.warning("Durable storage delete failed for %s: %s", key, exc)
tests/unit/test_object_storage.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for durable upload storage.
3
+
4
+ These exist because of a production incident. Uploads were written only to
5
+ UPLOAD_DIR, which lives on the container filesystem. HuggingFace Spaces
6
+ restarts the container on every deploy and the filesystem comes back as it was
7
+ baked into the image — empty — while the Postgres rows describing those files
8
+ survive. A document therefore kept listing, kept showing its analysis and kept
9
+ answering questions from its stored embeddings, right up until something asked
10
+ for the original bytes:
11
+
12
+ app.core.url_guard.UnsafeURLError: Stored file no longer exists
13
+
14
+ A re-analysis then destroyed the document's chunks before discovering the
15
+ source was gone, which turned a recoverable situation into data loss.
16
+ """
17
+
18
+ import os
19
+
20
+ import pytest
21
+
22
+ from app.core.url_guard import UnsafeURLError, storage_key_from_url
23
+ from app.services import object_storage
24
+
25
+ pytestmark = pytest.mark.unit
26
+
27
+
28
+ # ── Key extraction ─────────────────────────────────────────────────────────────
29
+
30
+
31
+ def test_storage_key_from_storage_url():
32
+ assert storage_key_from_url("storage://abc123.pdf") == "abc123.pdf"
33
+
34
+
35
+ def test_storage_key_from_legacy_file_url():
36
+ """Older rows hold an absolute path; only the basename is a usable key."""
37
+ assert storage_key_from_url("file:///app/uploads/abc123.pdf") == "abc123.pdf"
38
+
39
+
40
+ def test_storage_key_rejects_external_url():
41
+ with pytest.raises(UnsafeURLError):
42
+ storage_key_from_url("https://example.com/abc123.pdf")
43
+
44
+
45
+ def test_storage_key_rejects_empty_key():
46
+ with pytest.raises(UnsafeURLError):
47
+ storage_key_from_url("storage://")
48
+
49
+
50
+ # ── Configuration gating ───────────────────────────────────────────────────────
51
+
52
+
53
+ def test_not_configured_without_credentials(monkeypatch):
54
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_URL", "")
55
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
56
+ assert object_storage.is_configured() is False
57
+
58
+
59
+ def test_not_configured_with_only_a_url(monkeypatch):
60
+ """A URL without a key cannot authenticate, so it is not usable."""
61
+ monkeypatch.setattr(
62
+ object_storage.settings, "SUPABASE_URL", "https://x.supabase.co"
63
+ )
64
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
65
+ assert object_storage.is_configured() is False
66
+
67
+
68
+ def test_configured_with_both(monkeypatch):
69
+ monkeypatch.setattr(
70
+ object_storage.settings, "SUPABASE_URL", "https://x.supabase.co"
71
+ )
72
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "svc-key")
73
+ assert object_storage.is_configured() is True
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_unconfigured_calls_are_inert(monkeypatch):
78
+ """
79
+ With no credentials the module must not attempt any network call, so local
80
+ development and CI keep working exactly as before.
81
+ """
82
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_URL", "")
83
+ monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
84
+
85
+ assert await object_storage.put_object("k.pdf", b"data", "application/pdf") is False
86
+ assert await object_storage.get_object("k.pdf") is None
87
+ await object_storage.delete_object("k.pdf") # must not raise
88
+
89
+
90
+ # ── Recovery after the local copy is lost ──────────────────────────────────────
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_missing_local_file_is_restored_from_durable_storage(
95
+ monkeypatch, tmp_path
96
+ ):
97
+ """The exact scenario that lost a document in production."""
98
+ from app.services import extractors
99
+
100
+ monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
101
+ monkeypatch.setattr(object_storage, "is_configured", lambda: True)
102
+
103
+ async def fake_get(key):
104
+ assert key == "abc123.pdf"
105
+ return b"%PDF-1.4 recovered"
106
+
107
+ monkeypatch.setattr(object_storage, "get_object", fake_get)
108
+
109
+ data = await extractors._restore_from_durable_storage(
110
+ "storage://abc123.pdf", max_bytes=1024
111
+ )
112
+
113
+ assert data == b"%PDF-1.4 recovered"
114
+ # The local cache is repopulated so the next read does not go over the wire.
115
+ assert (tmp_path / "abc123.pdf").read_bytes() == b"%PDF-1.4 recovered"
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_missing_everywhere_reports_the_original_failure(monkeypatch, tmp_path):
120
+ from app.services import extractors
121
+
122
+ monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
123
+ monkeypatch.setattr(object_storage, "is_configured", lambda: True)
124
+
125
+ async def fake_get(key):
126
+ return None
127
+
128
+ monkeypatch.setattr(object_storage, "get_object", fake_get)
129
+
130
+ with pytest.raises(UnsafeURLError, match="no longer exists"):
131
+ await extractors._restore_from_durable_storage(
132
+ "storage://gone.pdf", max_bytes=1024
133
+ )
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_unconfigured_recovery_explains_how_to_fix_it(monkeypatch, tmp_path):
138
+ """
139
+ An operator reading this error should learn why the file vanished and what
140
+ to set so it stops happening, not just that it is absent.
141
+ """
142
+ from app.services import extractors
143
+
144
+ monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
145
+ monkeypatch.setattr(object_storage, "is_configured", lambda: False)
146
+
147
+ with pytest.raises(UnsafeURLError) as exc:
148
+ await extractors._restore_from_durable_storage(
149
+ "storage://gone.pdf", max_bytes=1024
150
+ )
151
+
152
+ message = str(exc.value)
153
+ assert "SUPABASE_URL" in message
154
+ assert "restart" in message
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_oversized_restored_file_is_rejected(monkeypatch, tmp_path):
159
+ """The size cap must hold on the recovery path too, not just on upload."""
160
+ from app.services import extractors
161
+
162
+ monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
163
+ monkeypatch.setattr(object_storage, "is_configured", lambda: True)
164
+
165
+ async def fake_get(key):
166
+ return b"x" * 5000
167
+
168
+ monkeypatch.setattr(object_storage, "get_object", fake_get)
169
+
170
+ with pytest.raises(UnsafeURLError, match="maximum allowed size"):
171
+ await extractors._restore_from_durable_storage(
172
+ "storage://big.pdf", max_bytes=1024
173
+ )
174
+
175
+ assert not os.path.exists(tmp_path / "big.pdf")