File size: 45,251 Bytes
2ecdf7d
 
 
 
 
 
 
0a46902
 
2ecdf7d
 
 
 
 
5084a8e
2ecdf7d
 
0a46902
2ecdf7d
 
 
2c1d035
2ecdf7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a46902
 
 
 
 
 
 
 
 
 
 
 
 
b8e40db
 
 
0a46902
 
 
 
 
 
2ecdf7d
 
 
 
 
5084a8e
 
 
e16bd16
 
 
 
 
 
5084a8e
 
2ecdf7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46fa98c
 
2ecdf7d
46fa98c
 
 
 
 
43acb00
46fa98c
 
43acb00
cb22a66
 
46fa98c
cb22a66
1787788
46fa98c
 
1787788
2ecdf7d
 
46fa98c
cb22a66
 
46fa98c
cb22a66
 
1787788
 
46fa98c
 
1787788
 
 
2ecdf7d
 
 
 
46fa98c
2ecdf7d
 
 
 
 
 
 
 
 
8e2dddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ecdf7d
 
 
 
 
 
 
 
 
 
 
 
 
 
2c1d035
2ecdf7d
 
 
 
 
 
 
 
 
 
 
 
 
2787e18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e6f09
939712a
 
 
 
 
 
 
 
 
 
70e6f09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2787e18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f52d90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2787e18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1787788
46fa98c
1787788
 
 
 
 
8e2dddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ecdf7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78482d8
46fa98c
2ecdf7d
 
 
78482d8
2ecdf7d
 
 
 
 
 
 
 
 
 
 
5fa3433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46fa98c
2feb0de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46fa98c
 
 
2feb0de
43acb00
46fa98c
43acb00
46fa98c
 
43acb00
 
 
46fa98c
43acb00
 
 
 
 
 
 
 
 
 
8e2dddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ecdf7d
2feb0de
 
 
 
 
 
 
2ecdf7d
 
 
78482d8
2ecdf7d
 
78482d8
2ecdf7d
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
from __future__ import annotations

import asyncio
import contextlib
import gzip
import io
import json
import random
import re
import struct
import threading
import time
import unittest
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import quote

IMPORT_ERROR = None
try:
    from backend import app
    import httpx
except ModuleNotFoundError as exc:
    app = None
    IMPORT_ERROR = exc

from tests.test_async_proxy import FakeResponse, FakeSession


SOURCE = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/book29.txt"
OBJECT = "objects/aa/" + "a" * 64
BUCKET_PATH = OBJECT + "/" + "b" * 16 + "/page-manifest.json"


def make_zip(files=None, comment=b"", zip64=False):
    output = io.BytesIO()
    with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for name, content in (files if files is not None else {"image.svg": b"<svg/>"}).items():
            archive.writestr(name, content)
        archive.comment = comment
    raw = output.getvalue()
    if not zip64:
        return raw
    offset = len(raw) - 22 - len(comment)
    end = list(struct.unpack("<4s4H2LH", raw[offset:offset + 22]))
    record64 = struct.pack("<4sQ2H2L4Q", b"PK\x06\x06", 44, 45, 45, 0, 0, end[4], end[4], end[5], end[6])
    locator = struct.pack("<4sLQL", b"PK\x06\x07", 0, offset, 1)
    end[3:7] = [0xffff, 0xffff, 0xffffffff, 0xffffffff]
    return raw[:offset] + record64 + locator + struct.pack("<4s4H2LH", *end) + comment


@unittest.skipIf(app is None, f"backend dependencies unavailable: {IMPORT_ERROR}")
class ReaderArchiveValidationTests(unittest.TestCase):
    def test_canonical_paths_preserve_character_encoder_semantics(self):
        prefix = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/"
        def reference(value):
            def replace(match):
                token = match.group()
                if re.fullmatch(r"%[0-9a-fA-F]{2}", token):
                    char = chr(int(token[1:], 16))
                    return char if re.fullmatch(r"[A-Za-z0-9._~-]", char) else token.upper()
                return quote(token, safe="")
            return prefix + re.sub(r"%[0-9a-fA-F]{2}|[^A-Za-z0-9._~/-]", replace, value)
        rng = random.Random(20260927)
        tokens = [chr(i) for i in range(128) if chr(i) not in "?#"]
        tokens += ["\u4e66", "\u00e9", "\U0001f600", "%2f", "%25", "%41", "%7e", "%G0", "%"]
        paths = ["%" + first + second for i in range(256)
                 for first in {f"{i:02x}"[0], f"{i:02X}"[0]}
                 for second in {f"{i:02x}"[1], f"{i:02X}"[1]}]
        paths += ["".join(rng.choices(tokens, k=40)) for _ in range(256)]
        for path in paths:
            self.assertEqual(app.canonical_reader_source_url(prefix + path), reference(path))
        for value in ("https://example.com/%ab", prefix + "a?raw=%ab#fragment"):
            self.assertEqual(app.canonical_reader_source_url(value), value)

    def test_base36_independent_fixed_vectors(self):
        for value, expected in ((0, "0"), (35, "z"), (36, "10"), (72, "20"), (1296, "100"), (2**64 - 1, "3w5e11264sgsf")):
            with self.subTest(value=value):
                self.assertEqual(app._base36(value), expected)
        self.assertEqual(app.reader_short_id(SOURCE), "0ym7e7wbcxau0")
        vectors = json.loads(Path(__file__).with_name("reader-contract-vectors.json").read_text())
        for value, expected in vectors["ids"]:
            self.assertEqual(app.reader_short_id(value), expected)
        prefix = "https://huggingface.co/datasets/VoiceOfML/VOMEBOOK/resolve/main/"
        for value, expected in vectors["canonicalPaths"]:
            self.assertEqual(app.canonical_reader_source_url(prefix + value), prefix + expected)
            self.assertEqual(app.canonical_reader_source_url(prefix + expected), prefix + expected)
            self.assertEqual(app.reader_short_id(prefix + value), app.reader_short_id(prefix + expected))
        self.assertEqual(app.reader_short_id(prefix + vectors["reportedBook"]["path"]), vectors["reportedBook"]["id"])
        for record, expected in vectors["ocr"]:
            self.assertEqual(app.build_txt_relative_path(record), expected)

    def test_valid_standard_zip64_comments_and_prepended_data(self):
        for zip64 in (False, True):
            for comment in (b"", b"archive comment"):
                for prefix in (b"", b"self-extracting prefix"):
                    with self.subTest(zip64=zip64, comment=comment, prefix=prefix):
                        raw = prefix + make_zip(comment=comment, zip64=zip64)
                        app.validate_reader_archive(raw)
                        self.assertEqual(app.read_reader_archive_member(raw, "image.svg"), b"<svg/>")
        app.validate_reader_archive(make_zip(files={}))

    def test_gzip_wrapper_has_bounded_expansion(self):
        raw = make_zip()
        self.assertEqual(app.prepare_reader_archive(gzip.compress(raw)), raw)
        with patch.object(app, "READER_RESOURCE_MAX_ARCHIVE_BYTES", len(raw) - 1):
            with self.assertRaises(app.ReaderArchiveLimitError):
                app.prepare_reader_archive(gzip.compress(raw))
        with self.assertRaises(app.ReaderArchiveInvalidError):
            app.prepare_reader_archive(b"\x1f\x8btruncated")

    def test_directory_limits_checked_before_zipfile_construction(self):
        for zip64 in (False, True):
            raw = make_zip(zip64=zip64)
            for limit in ("READER_ARCHIVE_MAX_CENTRAL_DIRECTORY_BYTES", "READER_ARCHIVE_MAX_ENTRIES"):
                with self.subTest(zip64=zip64, limit=limit), patch.object(app, limit, 0), patch.object(app.zipfile, "ZipFile") as parser:
                    with self.assertRaises(app.ReaderArchiveLimitError):
                        app.validate_reader_archive(raw)
                    parser.assert_not_called()

    def test_malformed_end_records_and_multidisk_archives(self):
        raw = make_zip()
        variants = [raw[:-1], b"PKnot-a-zip"]
        for offset, fmt, value in ((4, "H", 1), (10, "H", 2), (16, "L", len(raw) + 1), (20, "H", 8)):
            bad = bytearray(raw)
            struct.pack_into("<" + fmt, bad, len(raw) - 22 + offset, value)
            variants.append(bytes(bad))
        raw64 = make_zip(zip64=True)
        bad64 = bytearray(raw64)
        struct.pack_into("<L", bad64, len(raw64) - 22 - 20 + 4, 1)
        variants.extend((bytes(bad64), raw64[:-23] + raw64[-22:]))
        for index, bad in enumerate(variants):
            with self.subTest(index=index), self.assertRaises(app.ReaderArchiveInvalidError):
                app.validate_reader_archive(bad)

    def test_unsafe_members_and_compression_limits(self):
        for name in ("../escape", "/absolute", "a/../escape", "a//escape", "a\\..\\escape"):
            with self.subTest(name=name), self.assertRaises(app.ReaderArchiveInvalidError):
                app.validate_reader_archive(make_zip({name: b"content"}))
        with self.assertRaises(app.ReaderArchiveLimitError):
            app.validate_reader_archive(make_zip({"bomb": b"x" * 100000}))
        with patch.object(app, "READER_ARCHIVE_MAX_EXPANDED_BYTES", 3), self.assertRaises(app.ReaderArchiveLimitError):
            app.validate_reader_archive(make_zip({"small": b"1234"}))
        with self.assertRaises(app.ReaderArchiveInvalidError):
            app.validate_reader_archive(make_zip().replace(b"image.svg", b"im\x00ge.svg"))

    def test_accepted_sidecar_paths_match_the_delivery_validator(self):
        files = {}
        for mode, filenames in app.READER_ASSET_FILENAMES.items():
            for filename in filenames:
                for directory in ("", "converter-v1/", "b" * 16 + "/", "b" * 16 + "/converter-v1/"):
                    path = OBJECT + "/" + directory + filename
                    # Page manifests have only an optional version directory.
                    if filename == "page-manifest.json" and "converter" in directory:
                        continue
                    if filename in {"document.vcf", "document.ini"}:
                        continue
                    files[path] = {"s": 2, "m": mode, "p": path}
        for filename in ("document.vcf", "document.ini"):
            path = "documents/text/native/" + "d" * 64 + "/" + filename
            files[path] = {"s": 2, "m": "t", "p": path, "b": "vomebook/reader-assets-v2"}
        files["bucket"] = {"s": 2, "m": "p", "p": BUCKET_PATH, "b": "vomebook/pdf-pages-v2"}
        ebook_chapter_path = "chapters/ebook/epub/" + "c" * 64 + "/" + "b" * 16 + "/chapter-manifest.json"
        files["ebook-chapters"] = {
            "s": 2, "m": "e", "p": ebook_chapter_path, "b": "vomebook/reader-assets-v2",
            "c": ebook_chapter_path, "cb": "vomebook/reader-assets-v2",
        }
        files["spreadsheet-pages"] = {
            "s": 2, "m": "p", "p": OBJECT + "/1234567890abcdef/page-manifest.json",
            "b": "vomebook/pdf-pages-v2",
        }
        files["native-audio-bucket"] = {
            "s": 2, "m": "a", "p": "media/audio/native/" + "a" * 64 + "/audio.flac",
            "b": "vomebook/reader-assets-v2",
        }
        decoded = app.decode_reader_assets(gzip.compress(json.dumps({"v": 1, "f": files}).encode()))["f"]
        self.assertEqual(set(decoded), set(files))
        self.assertEqual(decoded["ebook-chapters"]["cb"], "vomebook/reader-assets-v2")
        self.assertEqual(
            app.reader_asset_url(decoded["spreadsheet-pages"]),
            "https://voiceofml-search.hf.space/api/reader-bucket-resource?path="
            + OBJECT + "/1234567890abcdef/page-manifest.json",
        )
        self.assertEqual(
            app.reader_asset_url(decoded["native-audio-bucket"]),
            "https://voiceofml-search.hf.space/api/reader-bucket-resource?path="
            + "media/audio/native/" + "a" * 64 + "/audio.flac",
        )
        self.assertEqual(app.READER_RESOURCE_TYPES["flac"], "audio/flac")
        self.assertEqual(app.reader_extension_for_asset(decoded["native-audio-bucket"], "flac"), "audio")
        for entry in decoded.values():
            if entry.get("b"):
                self.assertIsNotNone(app.READER_BUCKET_PATH_RE.fullmatch(entry["p"]))
            else:
                self.assertIsNotNone(app.READER_ASSET_PATH_RE.fullmatch(entry["p"]))
        for bad in (
            {"s": 2, "m": "e", "p": OBJECT + "/document.pdf"},
            {"s": 2, "m": "p", "p": OBJECT + "/document.pdf", "b": "vomebook/pdf-pages"},
            {"s": 2, "m": "p", "p": OBJECT + "/page-manifest.json", "b": "vomebook/pdf-pages"},
        ):
            decoded = app.decode_reader_assets(gzip.compress(json.dumps({"v": 1, "f": {"bad": bad}}).encode()))
            self.assertEqual(decoded["f"], {})


    def test_pdf_primary_document_survives_decoder_and_metadata_projection(self):
        path = "derived/test/" + "c" * 32 + "/document.pdf"
        entry = {"s": 2, "m": "p", "p": BUCKET_PATH, "b": "vomebook/pdf-pages-v2",
                 "pd": path, "pdb": "vomebook/pdf-pages-v2"}
        payload = gzip.compress(json.dumps({"v": 1, "f": {"book": entry}}).encode())
        self.assertEqual(app.decode_reader_assets(payload)["f"]["book"], entry)
        record = {"Repo": "VoiceOfML/Test", "File": "converted", "Extension": "caj", "Folder": []}
        resolved = app.reader_resolved_record(record, entry)
        self.assertEqual(resolved["extension"], "pdf-pages")
        self.assertEqual(resolved["pdf_document"],
                         "https://voiceofml-search.hf.space/api/reader-bucket-resource?path=" + path)
        enriched = app.add_reader_assets({"results": [record]},
                    {"f": {app.build_reader_asset_key(record): entry}})["results"][0]
        self.assertEqual(enriched["ReaderPdfDocument"], resolved["pdf_document"])
        for invalid in ({**entry, "pdb": "vomebook/pdf-pages"},
                        {**entry, "pdb": []},
                        {**entry, "pd": "../document.pdf"}, {**entry, "pd": BUCKET_PATH}):
            raw = gzip.compress(json.dumps({"v": 1, "f": {"book": invalid}}).encode())
            self.assertEqual(app.decode_reader_assets(raw)["f"], {})


@unittest.skipIf(app is None, f"backend dependencies unavailable: {IMPORT_ERROR}")
class ReaderBackendTests(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        self.saved_state = dict(app.app.state._state)
        app.initialize_reader_archive_state(app.app)
        app.initialize_reader_bucket_state(app.app)
        app.app.state.upstream_semaphore = asyncio.Semaphore(1)
        self.stack = contextlib.ExitStack()
        self.record = {"Repo": "VoiceOfML/Test", "File": "book29", "Extension": "txt", "Folder": []}
        self.assets = {"v": 1, "f": {}}
        self.stack.enter_context(patch.object(app, "get_reader_assets", AsyncMock(return_value=self.assets)))
        self.stack.enter_context(patch.object(app, "records", [self.record]))
        self.stack.enter_context(patch.object(app, "reader_record_key_indices", {app.build_reader_asset_key(self.record): 0}))
        self.stack.enter_context(patch.object(app, "reader_source_id_indices", {"0ym7e7wbcxau0": [0]}))
        self.stack.enter_context(patch("backend.upstream_http.upstream_redirect_cache", {}))

    async def asyncTearDown(self):
        await app.shutdown_reader_archive_state(app.app)
        await app.shutdown_reader_bucket_state(app.app)
        self.stack.close()
        app.app.state._state.clear()
        app.app.state._state.update(self.saved_state)

    def session(self, *responses):
        session = FakeSession(responses)
        app.app.state.http_session = session
        return session

    async def test_bucket_image_disconnect_stops_stream_and_releases_slot(self):
        path = "pages/image/bmp/" + "a" * 64 + "/pages/page-000001.webp"
        upstream = FakeResponse(chunks=(b"image",))
        self.session(upstream)
        with patch.object(app, "get_reader_bucket_resource", AsyncMock()) as buffered:
            response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
        buffered.assert_not_awaited()
        started = asyncio.Event()

        async def send(message):
            started.set()
            await asyncio.Event().wait()

        async def receive():
            await started.wait()
            return {"type": "http.disconnect"}

        await asyncio.wait_for(response({"type": "http", "asgi": {"spec_version": "2.0"}}, receive, send), 1)
        self.assertTrue(upstream.released)
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_cold_ocr_parse_is_shared_between_concurrent_readers(self):
        path = "derived/test/" + "a" * 32 + "/ocr/book-text.json.gz"
        pages = [{"page": 1, "text": "ok", "folded": "ok", "spans": [], "layout": {}}]
        with patch.object(app, "_load_reader_ocr_book", AsyncMock(return_value=pages)) as loader:
            first, second = await asyncio.gather(
                app.get_reader_ocr_book(path), app.get_reader_ocr_book(path)
            )
        self.assertIs(first, second)
        loader.assert_awaited_once_with(path)

    async def test_uncached_bucket_bodies_stream_without_filesystem_worker(self):
        prefix = 'a' * 64
        paths = [f'documents/text/txt/{prefix}/document.txt',
                 f'documents/web/html/{prefix}/document.html',
                 f'documents/office/docx/{prefix}/document.docx',
                 f'chapters/ebook/epub/{prefix}/'+'b'*16+'/chapters/chapter-0001.xhtml',
                 f'media/swf/native/{prefix}/document.swf']
        for path in paths:
            with self.subTest(path=path):
                upstream = FakeResponse(chunks=(b'body',))
                self.session(upstream)
                with patch.object(app, 'get_reader_bucket_resource', AsyncMock()) as buffered:
                    response = await app.api_reader_bucket_resource(path, SimpleNamespace(method='GET', headers={}))
                buffered.assert_not_awaited()
                self.assertEqual(b''.join([chunk async for chunk in response.body_iterator]), b'body')
                self.assertTrue(upstream.released)
                self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_disconnect_while_waiting_for_headers_releases_transport_slot(self):
        self.session()
        disconnected = asyncio.Event()
        started = asyncio.Event()
        cancelled = asyncio.Event()

        async def open_upstream(*args, **kwargs):
            started.set()
            try:
                await asyncio.Event().wait()
            finally:
                cancelled.set()

        request = SimpleNamespace(method="GET", headers={},
                                  is_disconnected=AsyncMock(side_effect=lambda: disconnected.is_set()))
        with patch.object(app, "open_download_response", side_effect=open_upstream):
            task = asyncio.create_task(app.stream_reader_document(request, SOURCE))
            await asyncio.wait_for(started.wait(), 1)
            disconnected.set()
            with self.assertRaises(asyncio.CancelledError):
                await asyncio.wait_for(task, 1)
        self.assertTrue(cancelled.is_set())
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_disconnect_while_queued_does_not_start_upstream(self):
        await app.app.state.upstream_semaphore.acquire()
        request = SimpleNamespace(method="GET", headers={}, is_disconnected=AsyncMock(return_value=True))
        with patch.object(app, "open_download_response", AsyncMock()) as opened:
            with self.assertRaises(asyncio.CancelledError):
                await asyncio.wait_for(app.stream_reader_document(request, SOURCE), 1)
        opened.assert_not_awaited()
        self.assertEqual(app.app.state.upstream_semaphore._value, 0)
        app.app.state.upstream_semaphore.release()

    async def test_cached_range_finishes_when_disconnect_probe_swallows_cancellation(self):
        app.app.state.reader_range_cache = app.OrderedDict()
        app.app.state.reader_range_cache_bytes = 3
        key = (SOURCE, 'bytes=0-2', app.READER_DOCUMENT_MAX_BYTES, None)
        app.app.state.reader_range_cache[key] = (time.monotonic(), b'pdf', {
            'Content-Type':'application/pdf', 'Content-Length':'3', 'Content-Range':'bytes 0-2/3'})

        async def probe():
            try:
                await asyncio.Event().wait()
            except asyncio.CancelledError:
                # Starlette's Request probe owns a cancellation scope.
                return False

        request = SimpleNamespace(method='GET', headers={'Range':'bytes=0-2'}, is_disconnected=probe)
        with patch.object(app, 'open_download_response', AsyncMock()) as opened:
            response = await asyncio.wait_for(app.stream_reader_document(request, SOURCE), .5)
        self.assertEqual(response.status_code, 206)
        self.assertEqual(response.body, b'pdf')
        self.assertEqual(response.headers['cache-control'], 'no-store')
        opened.assert_not_awaited()

    async def test_cold_resolve_waits_for_conversion_and_empty_failure_is_not_cacheable(self):
        with patch.object(app, "reader_assets_cache", {"v": 1, "f": {}}):
            await app.api_reader_resolve("0ym7e7wbcxau0")
            app.get_reader_assets.assert_awaited_with(wait=True)
        self.assets['f'][app.build_reader_asset_key(self.record)] = {
            's':2, 'm':'p', 'p':'documents/pdf/ps/'+'a'*64+'/document.pdf',
            'b':'vomebook/reader-assets-v2'}
        with patch.object(app, "reader_assets_cache", self.assets):
            response = await app.api_reader_resolve("0ym7e7wbcxau0")
            app.get_reader_assets.assert_awaited_with(wait=False)
        self.assertEqual(json.loads(response.body)['extension'], 'pdf')
        self.assets['f'].clear()
        response = await app.api_reader_resolve("0ym7e7wbcxau0")
        self.assertEqual(response.headers['cache-control'], 'no-store')

    async def test_bucket_audio_resource_uses_audio_mime_type(self):
        path = "media/audio/native/" + "a" * 64 + "/audio.flac"
        with patch.object(app, "get_reader_bucket_resource", AsyncMock(return_value=b"flac")):
            response = await app.api_reader_bucket_resource(path)
        self.assertEqual(response.media_type, "audio/flac")
        self.assertEqual(response.body, b"flac")

    async def test_bucket_image_disconnect_stops_stream_and_releases_slot(self):
        path = "pages/image/bmp/" + "a" * 64 + "/pages/page-000001.webp"
        upstream = FakeResponse(chunks=(b"image",))
        self.session(upstream)
        with patch.object(app, "get_reader_bucket_resource", AsyncMock()) as buffered:
            response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
        buffered.assert_not_awaited()
        started = asyncio.Event()

        async def send(message):
            started.set()
            await asyncio.Event().wait()

        async def receive():
            await started.wait()
            return {"type": "http.disconnect"}

        await asyncio.wait_for(response({"type": "http", "asgi": {"spec_version": "2.0"}}, receive, send), 1)
        self.assertTrue(upstream.released)
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_uncached_bucket_bodies_stream_without_filesystem_worker(self):
        prefix = 'a' * 64
        paths = [f'documents/text/txt/{prefix}/document.txt',
                 f'documents/web/html/{prefix}/document.html',
                 f'documents/office/docx/{prefix}/document.docx',
                 f'chapters/ebook/epub/{prefix}/'+'b'*16+'/chapters/chapter-0001.xhtml',
                 f'media/swf/native/{prefix}/document.swf']
        for path in paths:
            with self.subTest(path=path):
                upstream = FakeResponse(chunks=(b'body',))
                self.session(upstream)
                with patch.object(app, 'get_reader_bucket_resource', AsyncMock()) as buffered:
                    response = await app.api_reader_bucket_resource(path, SimpleNamespace(method='GET', headers={}))
                buffered.assert_not_awaited()
                self.assertEqual(b''.join([chunk async for chunk in response.body_iterator]), b'body')
                self.assertTrue(upstream.released)
                self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_disconnect_while_waiting_for_headers_releases_transport_slot(self):
        self.session()
        disconnected = asyncio.Event()
        started = asyncio.Event()
        cancelled = asyncio.Event()

        async def open_upstream(*args, **kwargs):
            started.set()
            try:
                await asyncio.Event().wait()
            finally:
                cancelled.set()

        request = SimpleNamespace(method="GET", headers={},
                                  is_disconnected=AsyncMock(side_effect=lambda: disconnected.is_set()))
        with patch.object(app, "open_download_response", side_effect=open_upstream):
            task = asyncio.create_task(app.stream_reader_document(request, SOURCE))
            await asyncio.wait_for(started.wait(), 1)
            disconnected.set()
            with self.assertRaises(asyncio.CancelledError):
                await asyncio.wait_for(task, 1)
        self.assertTrue(cancelled.is_set())
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_disconnect_while_queued_does_not_start_upstream(self):
        await app.app.state.upstream_semaphore.acquire()
        request = SimpleNamespace(method="GET", headers={}, is_disconnected=AsyncMock(return_value=True))
        with patch.object(app, "open_download_response", AsyncMock()) as opened:
            with self.assertRaises(asyncio.CancelledError):
                await asyncio.wait_for(app.stream_reader_document(request, SOURCE), 1)
        opened.assert_not_awaited()
        self.assertEqual(app.app.state.upstream_semaphore._value, 0)
        app.app.state.upstream_semaphore.release()

    async def test_cached_range_finishes_when_disconnect_probe_swallows_cancellation(self):
        app.app.state.reader_range_cache = app.OrderedDict()
        app.app.state.reader_range_cache_bytes = 3
        key = (SOURCE, 'bytes=0-2', app.READER_DOCUMENT_MAX_BYTES, None)
        app.app.state.reader_range_cache[key] = (time.monotonic(), b'pdf', {
            'Content-Type':'application/pdf', 'Content-Length':'3', 'Content-Range':'bytes 0-2/3'})

        async def probe():
            try:
                await asyncio.Event().wait()
            except asyncio.CancelledError:
                # Starlette's Request probe owns a cancellation scope.
                return False

        request = SimpleNamespace(method='GET', headers={'Range':'bytes=0-2'}, is_disconnected=probe)
        with patch.object(app, 'open_download_response', AsyncMock()) as opened:
            response = await asyncio.wait_for(app.stream_reader_document(request, SOURCE), .5)
        self.assertEqual(response.status_code, 206)
        self.assertEqual(response.body, b'pdf')
        self.assertEqual(response.headers['cache-control'], 'no-store')
        opened.assert_not_awaited()

    async def content(self, upstream, method="GET"):
        self.session(upstream)
        return await app.api_reader_content(SimpleNamespace(method=method, headers={}), SOURCE, None)

    def assert_isolated(self, response):
        policy = response.headers["content-security-policy"]
        self.assertIn("sandbox;", policy)
        self.assertIn("default-src 'none'", policy)
        self.assertNotIn("allow-scripts", policy)
        self.assertNotIn("allow-same-origin", policy)
        self.assertEqual(response.headers["x-content-type-options"], "nosniff")

    async def test_valid_resource_roundtrip_and_literal_http_member_names(self):
        files = {"literal%20name.svg": b"percent", "literal name.svg": b"space", "percent%25.svg": b"percent25", "\u4e66.svg": b"unicode"}
        upstream = FakeResponse(chunks=(make_zip(files),))
        session = self.session(upstream)
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.app), base_url="http://test") as client:
            for name, body in files.items():
                response = await client.get("/api/reader-resource", params={"book": SOURCE, "path": name})
                self.assertEqual(response.status_code, 200, response.text)
                self.assertEqual(response.content, body)
                self.assert_isolated(response)
            for unsafe in ("../escape", "/absolute", "a//b", "a/../b", "nul\x00"):
                response = await client.get("/api/reader-resource", params={"book": SOURCE, "path": unsafe})
                self.assertEqual(response.status_code, 400)
        self.assertEqual(len(session.urls), 1)
        self.assertTrue(upstream.released)

    async def test_archive_failures_are_not_cached_and_member_limits_are_reported(self):
        invalid = FakeResponse(chunks=(b"not-a-zip",))
        valid = FakeResponse(chunks=(make_zip(),))
        self.session(invalid, valid)
        self.assertEqual((await app.api_reader_resource(SOURCE, "image.svg")).status_code, 400)
        self.assertEqual(app.app.state.reader_archive_cache, {})
        self.assertEqual((await app.api_reader_resource(SOURCE, "missing.svg")).status_code, 404)
        with patch.object(app, "READER_RESOURCE_MAX_BYTES", 2):
            self.assertEqual((await app.api_reader_resource(SOURCE, "image.svg")).status_code, 413)
        self.assertTrue(invalid.released and valid.released)
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_archive_cold_requests_share_load_and_cancellation_preserves_waiter(self):
        started, release = asyncio.Event(), asyncio.Event()
        raw = make_zip()

        class SlowResponse(FakeResponse):
            def iter_chunked(self, _size):
                async def chunks():
                    started.set()
                    await release.wait()
                    yield raw
                return chunks()

        upstream = SlowResponse()
        session = self.session(upstream)
        first = asyncio.create_task(app.get_reader_archive(SOURCE))
        await asyncio.wait_for(started.wait(), 1)
        second = asyncio.create_task(app.get_reader_archive(SOURCE))
        await asyncio.sleep(0)
        first.cancel()
        with self.assertRaises(asyncio.CancelledError):
            await first
        release.set()
        self.assertEqual(await asyncio.wait_for(second, 2), raw)
        self.assertEqual(len(session.urls), 1)
        self.assertTrue(upstream.released)
        self.assertEqual(app.app.state.reader_archive_inflight, {})

    async def test_archive_cache_total_bytes_expiry_and_oversize_admission(self):
        raw = make_zip()
        session = self.session(*(FakeResponse(chunks=(raw,)) for _ in range(6)))
        with patch.object(app, "READER_ARCHIVE_CACHE_MAX_BYTES", len(raw) * 2):
            for index in range(3):
                await app.get_reader_archive(SOURCE + str(index))
            cache = app.app.state.reader_archive_cache
            self.assertNotIn(SOURCE + "0", cache)
            self.assertEqual(sum(len(item[1]) for item in cache.values()), len(raw) * 2)
            cache[SOURCE + "2"] = (time.monotonic() - app.READER_ARCHIVE_CACHE_TTL_SECONDS - 1, raw)
            await app.get_reader_archive(SOURCE + "2")
            self.assertEqual(len(session.urls), 4)
        with patch.object(app, "READER_ARCHIVE_CACHE_MAX_BYTES", len(raw) - 1):
            await app.get_reader_archive(SOURCE + "big")
            await app.get_reader_archive(SOURCE + "big")
            self.assertNotIn(SOURCE + "big", cache)
        self.assertEqual(len(session.urls), 6)

    async def test_archive_shutdown_releases_active_download(self):
        started = asyncio.Event()

        class BlockedResponse(FakeResponse):
            def iter_chunked(self, _size):
                async def chunks():
                    started.set()
                    await asyncio.Event().wait()
                    yield b""
                return chunks()

        upstream = BlockedResponse()
        self.session(upstream)
        request = asyncio.create_task(app.get_reader_archive(SOURCE))
        await asyncio.wait_for(started.wait(), 1)
        with patch.object(app, "READER_ARCHIVE_MAX_INFLIGHT", 1):
            rejected = await app.api_reader_resource(SOURCE + "other", "image.svg")
            self.assertEqual(rejected.status_code, 503)
        await app.shutdown_reader_archive_state(app.app)
        with self.assertRaises(asyncio.CancelledError):
            await request
        self.assertTrue(upstream.released)
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)
        self.assertEqual(app.app.state.reader_archive_cache, {})

    async def test_worker_cancellation_retains_slot_until_thread_finishes(self):
        app.app.state.reader_archive_worker_semaphore = asyncio.Semaphore(1)
        started, release, second_started = threading.Event(), threading.Event(), threading.Event()

        def blocking():
            started.set()
            release.wait(2)

        first = asyncio.create_task(app.run_reader_archive_worker(blocking))
        self.assertTrue(await asyncio.to_thread(started.wait, 1))
        first.cancel()
        second = asyncio.create_task(app.run_reader_archive_worker(second_started.set))
        try:
            with self.assertRaises(asyncio.CancelledError):
                await asyncio.wait_for(first, 1)
            await asyncio.sleep(0.02)
            self.assertEqual(len(app.app.state.reader_archive_workers), 1)
            self.assertFalse(second_started.is_set())
        finally:
            release.set()
            results = await asyncio.gather(first, second, return_exceptions=True)
        self.assertIsInstance(results[0], asyncio.CancelledError)
        self.assertTrue(second_started.is_set())

    async def test_archive_shutdown_waits_for_abandoned_worker(self):
        started, release = threading.Event(), threading.Event()

        def blocking():
            started.set()
            release.wait(2)

        caller = asyncio.create_task(app.run_reader_archive_worker(blocking))
        self.assertTrue(await asyncio.to_thread(started.wait, 1))
        caller.cancel()
        with self.assertRaises(asyncio.CancelledError):
            await caller
        shutdown = asyncio.create_task(app.shutdown_reader_archive_state(app.app))
        try:
            await asyncio.sleep(0.02)
            self.assertFalse(shutdown.done())
        finally:
            release.set()
            await asyncio.wait_for(shutdown, 1)
        self.assertEqual(app.app.state.reader_archive_workers, set())

    async def test_content_isolation_for_active_document_mime_types(self):
        for mime in ("text/html", "application/xhtml+xml", "image/svg+xml"):
            with self.subTest(mime=mime):
                response = await self.content(FakeResponse(headers={"Content-Type": mime}, chunks=(b"active content",)))
                self.assert_isolated(response)
                self.assertEqual(b"".join([part async for part in response.body_iterator]), b"active content")

    async def test_compressed_upstream_is_rejected_before_body_and_on_head(self):
        for method in ("GET", "HEAD"):
            for status in (200, 206):
                with self.subTest(method=method, status=status):
                    upstream = FakeResponse(status, {"Content-Encoding": "gzip", "Content-Length": "35"}, chunks=(gzip.compress(b"reader text\n" * 20),))
                    with patch.object(upstream, "iter_chunked", side_effect=AssertionError("encoded body must not be read")):
                        response = await self.content(upstream, method)
                    self.assertEqual(response.status_code, 502)
                    self.assertTrue(upstream.released)
                    self.assertEqual(app.app.state.upstream_semaphore._value, 1)
                    self.assertIs(app.app.state.http_session.urls[0][1]["auto_decompress"], False)

    async def test_stream_cleanup_before_start_and_after_first_body(self):
        for fail_at in ("http.response.start", "http.response.body"):
            with self.subTest(fail_at=fail_at):
                upstream = FakeResponse(chunks=(b"one", b"two"))
                with patch.object(upstream, "release", wraps=upstream.release) as released:
                    response = await self.content(upstream)

                    async def send(message):
                        if message["type"] == fail_at:
                            raise OSError("client disconnected")

                    async def receive():
                        return {"type": "http.disconnect"}

                    with self.assertRaises(Exception):
                        await response({"type": "http", "asgi": {"spec_version": "2.4"}}, receive, send)
                    released.assert_called_once()
                self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_stream_cleanup_for_legacy_asgi_disconnect_before_iteration(self):
        upstream = FakeResponse()
        response = await self.content(upstream)
        started = asyncio.Event()

        async def send(message):
            started.set()
            await asyncio.Event().wait()

        async def receive():
            await started.wait()
            return {"type": "http.disconnect"}

        await asyncio.wait_for(response({"type": "http", "asgi": {"spec_version": "2.0"}}, receive, send), 1)
        self.assertTrue(upstream.released)
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_stream_cleanup_after_normal_asgi_completion_is_exactly_once(self):
        upstream = FakeResponse(chunks=(b"one", b"two"))
        messages = []
        with patch.object(upstream, "release", wraps=upstream.release) as released:
            response = await self.content(upstream)
            async def send(message):
                messages.append(message)
            await response({"type": "http", "asgi": {"spec_version": "2.4"}}, AsyncMock(), send)
            released.assert_called_once()
        self.assertEqual(b"".join(message.get("body", b"") for message in messages), b"onetwo")
        self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_malformed_headers_and_not_modified_release_upstream(self):
        cases = (
            (200, {"Content-Length": "invalid"}, 502),
            (206, {"Content-Range": "bytes " + "9" * 5000 + "-1/2"}, 502),
            (206, {"Content-Range": "bytes 0-1/2", "Content-Length": "1"}, 502),
            (304, {"ETag": "unchanged"}, 304),
        )
        for status, headers, expected in cases:
            with self.subTest(status=status, expected=expected):
                upstream = FakeResponse(status, headers)
                response = await self.content(upstream)
                self.assertEqual(response.status_code, expected)
                self.assertTrue(upstream.released)
                self.assertEqual(app.app.state.upstream_semaphore._value, 1)

    async def test_bucket_get_head_direct_and_by_source_id(self):
        self.assets["f"][app.build_reader_asset_key(self.record)] = {"s": 2, "m": "p", "p": BUCKET_PATH, "b": "vomebook/pdf-pages-v2"}
        body = b'{"kind":"pdf-pages"}'
        with patch.object(app, "read_reader_bucket_resource", return_value=body) as read:
            async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.app), base_url="http://test") as client:
                cases = (("/api/reader-bucket-resource", {"path": BUCKET_PATH}), ("/api/reader-content", {"id": "0ym7e7wbcxau0"}))
                for route, params in cases:
                    for method in ("GET", "HEAD"):
                        with self.subTest(route=route, params=params, method=method):
                            response = await client.request(method, route, params=params)
                            self.assertEqual(response.status_code, 200, response.text)
                            self.assertEqual(response.content, body if method == "GET" else b"")
                            self.assertEqual(response.headers["content-length"], str(len(body)))
                            self.assertIn("application/json", response.headers["content-type"])
                            self.assert_isolated(response)
            self.assertEqual(read.call_count, 1)

    async def test_cached_manifest_compression_keeps_exact_payload_and_head_length(self):
        body = json.dumps({"title": "完整目录", "chapters": ["章节" + str(i) for i in range(300)]},
                          ensure_ascii=False).encode()
        with patch.object(app, "read_reader_bucket_resource", return_value=body) as read:
            async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.app), base_url="http://test") as client:
                for encoding in ("gzip", "identity"):
                    response = await client.get("/api/reader-bucket-resource", params={"path": BUCKET_PATH},
                                                headers={"Accept-Encoding": encoding})
                    self.assertEqual(response.content, body)
                    if encoding == "gzip":
                        self.assertEqual(response.headers["content-encoding"], "gzip")
                        self.assertLess(int(response.headers["content-length"]), len(body))
                    else:
                        self.assertNotIn("content-encoding", response.headers)
                response = await client.head("/api/reader-bucket-resource", params={"path": BUCKET_PATH},
                                             headers={"Accept-Encoding": "gzip"})
                self.assertEqual(response.content, b"")
                self.assertEqual(int(response.headers["content-length"]), len(body))
                self.assertNotEqual(response.headers.get("content-encoding"), "gzip")
            self.assertEqual(read.call_count, 1)

    async def test_missing_v2_bucket_resource_does_not_request_retired_dataset(self):
        class Stream:
            def __init__(self, payload):
                self.payload = payload

            def __enter__(self):
                return self

            def __exit__(self, *_args):
                return False

            def read(self, _limit):
                return self.payload

        class FileSystem:
            def __init__(self):
                self.uris = []

            def open(self, uri, _mode):
                self.uris.append(uri)
                if uri.startswith("hf://buckets/"):
                    raise FileNotFoundError(uri)
                return Stream(b"dataset fallback")

        filesystem = FileSystem()
        with patch.object(app, "HfFileSystem", return_value=filesystem):
            with self.assertRaises(FileNotFoundError):
                app.read_reader_bucket_resource("documents/web/native/" + "a" * 64 + "/document.html")
        self.assertEqual(filesystem.uris, ["hf://buckets/vomebook/reader-assets-v2/documents/web/native/" + "a" * 64 + "/document.html"])

    async def test_bucket_chapter_sidecar_resolves_to_shared_bucket_endpoint(self):
        chapter_path = "chapters/ebook/epub/" + "c" * 64 + "/" + "b" * 16 + "/chapter-manifest.json"
        self.assets["f"][app.build_reader_asset_key(self.record)] = {
            "s": 2, "m": "e", "p": chapter_path, "b": "vomebook/reader-assets-v2",
            "c": chapter_path, "cb": "vomebook/reader-assets-v2",
        }
        resolved = json.loads((await app.api_reader_resolve("0ym7e7wbcxau0")).body)
        self.assertEqual(resolved["extension"], "epub-chapters")
        self.assertIn("/api/reader-bucket-resource?path=chapters/ebook/", resolved["chapter_manifest"])
        self.assertEqual(resolved["chapter_manifest"].count("pdf-pages"), 0)
        bucket_body = b'{"kind":"epub-chapters"}'
        bucket_url = "/api/reader-bucket-resource?path=" + quote(chapter_path, safe="")
        with patch.object(app, "read_reader_bucket_resource", return_value=bucket_body) as read:
            response = await app.api_reader_content(
                SimpleNamespace(method="GET", headers={}), bucket_url, None)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.body, bucket_body)
        self.assertEqual(read.call_args.args[0], chapter_path)

    async def test_cold_resolve_waits_for_conversion_and_empty_failure_is_not_cacheable(self):
        with patch.object(app, "reader_assets_cache", {"v": 1, "f": {}}):
            await app.api_reader_resolve("0ym7e7wbcxau0")
            app.get_reader_assets.assert_awaited_with(wait=True)
        self.assets['f'][app.build_reader_asset_key(self.record)] = {
            's':2, 'm':'p', 'p':'documents/pdf/ps/'+'a'*64+'/document.pdf',
            'b':'vomebook/reader-assets-v2'}
        with patch.object(app, "reader_assets_cache", self.assets):
            response = await app.api_reader_resolve("0ym7e7wbcxau0")
            app.get_reader_assets.assert_awaited_with(wait=False)
        self.assertEqual(json.loads(response.body)['extension'], 'pdf')
        self.assets['f'].clear()
        response = await app.api_reader_resolve("0ym7e7wbcxau0")
        self.assertEqual(response.headers['cache-control'], 'no-store')

    async def test_bucket_content_rejects_arbitrary_relative_and_ambiguous_paths(self):
        for url in (
            "https://[broken", "/api/other?path=x", "//evil.test/api/reader-bucket-resource?path=x",
            "/api/reader-bucket-resource?path=x&path=y",
            "/api/reader-bucket-resource?bucket=vomebook%2Fpdf-optimized&path=x",
            "/api/reader-bucket-resource?path=../escape",
            "https://huggingface.co/buckets/vomebook/pdf-optimized/resolve/objects/aa/document.pdf",
        ):
            response = await app.api_reader_content(SimpleNamespace(method="GET", headers={}), url, None)
            self.assertIn(response.status_code, (400, 403))

    async def test_source_id_does_not_hash_originals_and_conversion_precedence_agrees(self):
        asset = {"s": 2, "m": "p", "p": OBJECT + "/page-manifest.json", "c": OBJECT + "/chapters-v1/chapter-manifest.json", "f": OBJECT + "/pdf-v1/document.pdf"}
        self.assets["f"][app.build_reader_asset_key(self.record)] = asset
        resolved = json.loads((await app.api_reader_resolve("0ym7e7wbcxau0")).body)
        enriched = app.add_reader_assets({"results": [self.record]}, self.assets)["results"][0]
        self.assertEqual(resolved["extension"], "pdf-pages")
        self.assertEqual(resolved["extension"], enriched["ReaderExtension"])
        self.assertEqual(resolved["chapter_manifest"], enriched["ReaderChapterManifest"])
        self.assertEqual(resolved["fallback"], enriched["ReaderFallback"])


if __name__ == "__main__":
    unittest.main()