freenet 0.2.114

Freenet core software
Documentation
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
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
5066
5067
5068
5069
5070
5071
5072
5073
5074
5075
5076
5077
5078
5079
5080
5081
5082
5083
5084
5085
5086
5087
5088
5089
5090
5091
5092
5093
5094
5095
5096
5097
5098
5099
5100
5101
5102
5103
5104
5105
5106
5107
5108
5109
5110
5111
5112
5113
5114
5115
5116
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
5133
5134
5135
5136
5137
5138
5139
5140
5141
5142
5143
5144
5145
5146
5147
5148
5149
5150
5151
5152
5153
5154
5155
5156
5157
5158
5159
5160
5161
5162
5163
5164
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
5182
5183
5184
5185
5186
5187
5188
5189
5190
5191
5192
5193
5194
5195
5196
5197
5198
5199
5200
5201
5202
5203
5204
5205
5206
5207
5208
5209
5210
5211
5212
5213
5214
5215
5216
5217
5218
5219
5220
5221
5222
5223
5224
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
5252
5253
5254
5255
5256
5257
5258
5259
5260
5261
5262
5263
5264
5265
5266
5267
5268
5269
5270
5271
5272
5273
5274
5275
5276
5277
5278
5279
5280
5281
5282
5283
5284
5285
5286
5287
5288
5289
5290
5291
5292
5293
5294
5295
5296
5297
5298
5299
5300
5301
5302
5303
5304
5305
5306
5307
5308
5309
5310
5311
5312
5313
5314
5315
5316
5317
5318
5319
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
5368
5369
5370
5371
5372
5373
5374
5375
5376
5377
5378
5379
5380
5381
5382
5383
5384
5385
5386
5387
5388
5389
5390
5391
5392
5393
5394
5395
5396
5397
5398
5399
5400
5401
5402
5403
5404
5405
5406
5407
5408
5409
5410
5411
5412
5413
5414
5415
5416
5417
5418
5419
5420
5421
5422
5423
5424
5425
5426
5427
5428
5429
5430
5431
5432
5433
5434
5435
5436
5437
5438
5439
5440
5441
5442
5443
5444
5445
5446
5447
5448
5449
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
5472
5473
5474
5475
5476
5477
5478
5479
5480
5481
5482
5483
5484
5485
5486
5487
5488
5489
5490
5491
5492
5493
5494
5495
5496
5497
5498
5499
5500
5501
5502
5503
5504
5505
5506
5507
5508
5509
5510
5511
5512
5513
5514
5515
5516
5517
5518
5519
5520
5521
5522
5523
5524
5525
5526
5527
5528
5529
5530
5531
5532
5533
5534
5535
5536
5537
5538
5539
5540
5541
5542
5543
5544
5545
5546
5547
5548
5549
5550
5551
5552
5553
5554
5555
5556
5557
5558
5559
5560
5561
5562
5563
5564
5565
5566
5567
5568
5569
5570
5571
5572
5573
5574
5575
5576
5577
5578
5579
5580
5581
5582
5583
5584
5585
5586
5587
5588
5589
5590
5591
5592
5593
5594
5595
5596
5597
5598
5599
5600
5601
5602
5603
5604
5605
5606
5607
5608
5609
5610
5611
5612
5613
5614
5615
5616
5617
5618
5619
5620
5621
5622
5623
5624
5625
5626
5627
5628
5629
5630
5631
5632
5633
5634
5635
5636
5637
5638
5639
5640
5641
5642
5643
5644
5645
5646
5647
5648
5649
5650
5651
5652
5653
5654
5655
5656
5657
5658
5659
5660
5661
5662
5663
5664
5665
5666
5667
5668
5669
5670
5671
5672
5673
5674
5675
5676
5677
5678
5679
5680
5681
5682
5683
5684
5685
5686
5687
5688
5689
5690
5691
5692
5693
5694
5695
5696
5697
5698
5699
5700
5701
5702
5703
5704
5705
5706
5707
5708
5709
5710
5711
5712
5713
5714
5715
5716
5717
5718
5719
5720
5721
5722
5723
5724
5725
5726
5727
5728
5729
5730
5731
5732
5733
5734
5735
5736
5737
5738
5739
5740
5741
5742
5743
5744
5745
5746
5747
5748
5749
5750
5751
5752
5753
5754
5755
5756
5757
5758
5759
5760
5761
5762
5763
5764
5765
5766
5767
5768
5769
5770
5771
5772
5773
5774
5775
5776
5777
5778
5779
5780
5781
5782
5783
5784
5785
5786
5787
5788
5789
5790
5791
5792
5793
5794
5795
5796
5797
5798
5799
5800
5801
5802
5803
5804
5805
5806
5807
5808
5809
5810
5811
5812
5813
5814
5815
5816
5817
5818
5819
5820
5821
5822
5823
5824
5825
5826
5827
5828
5829
5830
5831
5832
5833
5834
5835
5836
5837
5838
5839
5840
5841
5842
5843
5844
5845
5846
5847
5848
5849
5850
5851
5852
5853
5854
5855
5856
5857
5858
5859
5860
5861
5862
5863
5864
5865
5866
5867
5868
5869
5870
5871
5872
5873
5874
5875
5876
5877
5878
5879
5880
5881
5882
5883
5884
5885
5886
5887
5888
5889
5890
5891
5892
5893
5894
5895
5896
5897
5898
5899
5900
5901
5902
5903
5904
5905
5906
5907
5908
5909
5910
5911
5912
5913
5914
5915
5916
5917
5918
5919
5920
5921
5922
5923
5924
5925
5926
5927
5928
5929
5930
5931
5932
5933
5934
5935
5936
5937
5938
5939
5940
5941
5942
5943
5944
5945
5946
5947
5948
5949
5950
5951
5952
5953
5954
5955
5956
5957
5958
5959
5960
5961
5962
5963
5964
5965
5966
5967
5968
5969
5970
5971
5972
5973
5974
5975
5976
5977
5978
5979
5980
5981
5982
5983
5984
5985
5986
5987
5988
5989
5990
5991
5992
5993
5994
5995
5996
5997
5998
5999
6000
6001
6002
6003
6004
6005
6006
6007
6008
6009
6010
6011
6012
6013
6014
6015
6016
6017
6018
6019
6020
6021
6022
6023
6024
6025
6026
6027
6028
6029
6030
6031
6032
6033
6034
6035
6036
6037
6038
6039
6040
6041
6042
6043
6044
6045
6046
6047
6048
6049
6050
6051
6052
6053
6054
6055
6056
6057
6058
6059
6060
6061
6062
6063
6064
6065
6066
6067
6068
6069
6070
6071
6072
6073
6074
6075
6076
6077
6078
6079
6080
6081
6082
6083
6084
6085
6086
6087
6088
6089
6090
6091
6092
6093
6094
6095
6096
6097
6098
6099
6100
6101
6102
6103
6104
6105
6106
6107
6108
6109
6110
6111
6112
6113
6114
6115
6116
6117
6118
6119
6120
6121
6122
6123
6124
6125
6126
6127
6128
6129
6130
6131
6132
6133
6134
6135
6136
6137
6138
6139
6140
6141
6142
6143
6144
6145
6146
6147
6148
6149
6150
6151
6152
6153
6154
6155
6156
6157
6158
6159
6160
6161
6162
6163
6164
6165
6166
6167
6168
6169
6170
6171
6172
6173
6174
6175
6176
6177
6178
6179
6180
6181
6182
6183
6184
6185
6186
6187
6188
6189
6190
6191
6192
6193
6194
6195
6196
6197
6198
6199
6200
6201
6202
6203
6204
6205
6206
6207
6208
6209
6210
6211
6212
6213
6214
6215
6216
6217
6218
6219
6220
6221
6222
6223
6224
6225
6226
6227
6228
6229
6230
6231
6232
6233
6234
6235
6236
6237
6238
6239
6240
6241
6242
6243
6244
6245
6246
6247
6248
6249
6250
6251
6252
6253
6254
6255
6256
6257
6258
6259
6260
6261
6262
6263
6264
6265
6266
6267
6268
6269
6270
6271
6272
6273
6274
6275
6276
6277
6278
6279
6280
6281
6282
6283
6284
6285
6286
6287
6288
6289
6290
6291
6292
6293
6294
6295
6296
6297
6298
6299
6300
6301
6302
6303
6304
6305
6306
6307
6308
6309
6310
6311
6312
6313
6314
6315
6316
6317
6318
6319
6320
6321
6322
6323
6324
6325
6326
6327
6328
6329
6330
6331
6332
6333
6334
6335
6336
6337
6338
6339
6340
6341
6342
6343
6344
6345
6346
6347
6348
6349
6350
6351
6352
6353
6354
6355
6356
6357
6358
6359
6360
6361
6362
6363
6364
6365
6366
6367
6368
6369
6370
6371
6372
6373
6374
6375
6376
6377
6378
6379
6380
6381
6382
6383
6384
6385
6386
6387
6388
6389
6390
6391
6392
6393
6394
6395
6396
6397
6398
6399
6400
6401
6402
6403
6404
6405
6406
6407
6408
6409
6410
6411
6412
6413
6414
6415
6416
6417
6418
6419
6420
6421
6422
6423
6424
6425
6426
6427
6428
6429
6430
6431
6432
6433
6434
6435
6436
6437
6438
6439
6440
6441
6442
6443
6444
6445
6446
6447
6448
6449
6450
6451
6452
6453
6454
6455
6456
6457
6458
6459
6460
6461
6462
6463
6464
6465
6466
6467
6468
6469
6470
6471
6472
6473
6474
6475
6476
6477
6478
6479
6480
6481
6482
6483
6484
6485
6486
6487
6488
6489
6490
6491
6492
6493
6494
6495
6496
6497
6498
6499
6500
6501
6502
6503
6504
6505
6506
6507
6508
6509
6510
6511
6512
6513
6514
6515
6516
6517
6518
6519
6520
6521
6522
6523
6524
6525
6526
6527
6528
6529
6530
6531
6532
6533
6534
6535
6536
6537
6538
6539
6540
6541
6542
6543
6544
6545
6546
6547
6548
6549
6550
6551
6552
6553
6554
6555
6556
6557
6558
6559
6560
6561
6562
6563
6564
6565
6566
6567
6568
6569
6570
6571
6572
6573
6574
6575
6576
6577
6578
6579
6580
6581
6582
6583
6584
6585
6586
6587
6588
6589
6590
6591
6592
6593
6594
6595
6596
6597
6598
6599
6600
6601
6602
6603
6604
6605
6606
6607
6608
6609
6610
6611
6612
6613
6614
6615
6616
6617
6618
6619
6620
6621
6622
6623
6624
6625
6626
6627
6628
6629
6630
6631
6632
6633
6634
6635
6636
6637
6638
6639
6640
6641
6642
6643
6644
6645
6646
6647
6648
6649
6650
6651
6652
6653
6654
6655
6656
6657
6658
6659
6660
6661
6662
6663
6664
6665
6666
6667
6668
6669
6670
6671
6672
6673
6674
6675
6676
6677
6678
6679
6680
6681
6682
6683
6684
6685
6686
6687
6688
6689
6690
6691
6692
6693
6694
6695
6696
6697
6698
6699
6700
6701
6702
6703
6704
6705
6706
6707
6708
6709
6710
6711
6712
6713
6714
6715
6716
6717
6718
6719
6720
6721
6722
6723
6724
6725
6726
6727
6728
6729
6730
6731
6732
6733
6734
6735
6736
6737
6738
6739
6740
6741
6742
6743
6744
6745
6746
6747
6748
6749
6750
6751
6752
6753
6754
6755
6756
6757
6758
6759
6760
6761
6762
6763
6764
6765
6766
6767
6768
6769
6770
6771
6772
6773
6774
6775
6776
6777
6778
6779
6780
6781
6782
6783
6784
6785
6786
6787
6788
6789
6790
6791
6792
6793
6794
6795
6796
6797
6798
6799
6800
6801
6802
6803
6804
6805
6806
6807
6808
6809
6810
6811
6812
6813
6814
6815
6816
6817
6818
6819
6820
6821
6822
6823
6824
6825
6826
6827
6828
6829
6830
6831
6832
6833
6834
6835
6836
6837
6838
6839
6840
6841
6842
6843
6844
6845
6846
6847
6848
6849
6850
6851
6852
6853
6854
6855
6856
6857
6858
6859
6860
6861
6862
6863
6864
6865
6866
6867
6868
6869
6870
6871
6872
6873
6874
6875
6876
6877
6878
6879
6880
6881
6882
6883
6884
6885
6886
6887
6888
6889
6890
6891
6892
6893
6894
6895
6896
6897
6898
6899
6900
6901
6902
6903
6904
6905
6906
6907
6908
6909
6910
6911
6912
6913
6914
6915
6916
6917
6918
6919
6920
6921
6922
6923
6924
6925
6926
6927
6928
6929
6930
6931
6932
6933
6934
6935
6936
6937
6938
6939
6940
6941
6942
6943
6944
6945
6946
6947
6948
6949
6950
6951
6952
//! Handle the `web` part of the bundles.
//!
//! Contract web apps are served inside sandboxed iframes to provide origin isolation.
//! The local API server returns a "shell" page that holds the auth token and
//! proxies WebSocket connections via postMessage, while the contract runs in an
//! `<iframe sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals"
//!         allow="clipboard-read; clipboard-write">`
//! with an opaque origin that cannot access other contracts' data.
//! Popups inherit the sandbox (no `allow-popups-to-escape-sandbox`); external links
//! are opened via the `open_url` shell bridge message to avoid CORS issues. The
//! injected interceptor also overrides programmatic `window.open` in the iframe,
//! routing http(s) opens through the same `open_url` bridge so a new tab gets the
//! shell's real origin instead of a sandbox-inheriting opaque one (freenet-core#4645).
//! Sandbox content is protected from top-level access via Sec-Fetch-Dest checks in client_api.rs.

use std::{
    collections::HashMap,
    path::{Path, PathBuf},
    sync::{Arc, LazyLock},
    time::{Duration, SystemTime},
};

use axum::response::{Html, IntoResponse};
use dashmap::DashMap;
use freenet_stdlib::{
    client_api::{
        ClientRequest, ContractRequest, ContractResponse, ErrorKind, HostResponse, RequestError,
    },
    prelude::*,
};
use tokio::time::Instant;
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};

use crate::client_events::AuthToken;

use super::{
    ApiVersion, ClientConnection, HostCallbackResult,
    app_packaging::{WebApp, WebContractError},
    client_api::HttpClientApiRequest,
    errors::WebSocketApiError,
};
use tracing::{debug, instrument};

/// Per-contract lock serializing mutations of the webapp cache directory.
///
/// A typical first-time page load of a contract fans out several concurrent
/// subresource requests (`<script>`, `<link>`, `<img>`). Before this lock
/// existed, each one independently observed the cache as cold and raced
/// through `remove_dir_all` + `create_dir_all` + `unpack` against the same
/// target directory, corrupting the unpacked tree and sometimes leaving a
/// valid-looking hash file pointing at a partially-written archive.
///
/// Entries are retained for the lifetime of the process. Each lock is a
/// three-word `tokio::sync::Mutex`, so the memory overhead for a node that
/// has seen N distinct web contracts is trivially bounded.
static CONTRACT_CACHE_LOCKS: LazyLock<DashMap<ContractInstanceId, Arc<tokio::sync::Mutex<()>>>> =
    LazyLock::new(DashMap::new);

async fn acquire_cache_lock(instance_id: &ContractInstanceId) -> tokio::sync::OwnedMutexGuard<()> {
    let mutex = CONTRACT_CACHE_LOCKS
        .entry(*instance_id)
        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
        .clone();
    mutex.lock_owned().await
}

/// How long a contract's extracted webapp cache is trusted before the next
/// request reconciles it against current network state.
///
/// `serve_sandbox_content` (the `?__sandbox=1` iframe handler) and
/// `variable_content` (subresource handler) both serve from the on-disk cache.
/// Without a freshness check, a republished contract keeps serving the old
/// bundle on these paths until the shell root (`/`) is hit again — only
/// `contract_home` unconditionally re-fetches. See #3977.
///
/// A short TTL re-runs `ensure_contract_cached` periodically. The actual
/// re-extraction still only happens when the state hash changed (see
/// `unpack_if_stale`), so the cost of a same-state refresh is one network GET,
/// not a disk rewrite. 30s keeps the publish-then-verify loop snappy while
/// bounding the GET rate to at most one per contract per window.
const CONTRACT_CACHE_REFRESH_TTL: Duration = Duration::from_secs(30);

/// Per-step timeout for the local presence query in `is_locally_known`.
/// Bounds how long a subresource request waits on the node for the
/// connection-id assignment and the diagnostics answer. On elapse the gate
/// fails closed (treats the contract as unknown), so a wedged or spammed node
/// can't pin request tasks open under a spray of unknown keys.
const PRESENCE_QUERY_TIMEOUT: Duration = Duration::from_secs(5);

/// Last time each contract's cache was reconciled against the network via
/// `ensure_contract_cached`. Used to gate the TTL refresh so the sandbox and
/// subresource paths don't issue a network GET on every request.
///
/// Like `CONTRACT_CACHE_LOCKS`, entries are retained for the process lifetime;
/// each is a single `Instant`, so the footprint is bounded by the number of
/// distinct web contracts the node has served.
static CONTRACT_CACHE_REFRESH: LazyLock<DashMap<ContractInstanceId, Instant>> =
    LazyLock::new(DashMap::new);

/// Per-contract lock serializing the *decision* to issue a staleness-refresh
/// GET, so a fan-out of concurrent subresource requests after the TTL expiry
/// issues at most one `ensure_contract_cached` GET per contract per window.
///
/// This is deliberately distinct from `CONTRACT_CACHE_LOCKS`: that lock guards
/// the on-disk unpack and is re-taken inside `unpack_if_stale`. `tokio`'s mutex
/// is not reentrant, so the refresh gate — which is held *across* the GET (and
/// therefore across `unpack_if_stale`'s own lock acquisition) — must use its
/// own mutex to avoid a self-deadlock.
static CONTRACT_REFRESH_LOCKS: LazyLock<DashMap<ContractInstanceId, Arc<tokio::sync::Mutex<()>>>> =
    LazyLock::new(DashMap::new);

async fn acquire_refresh_lock(
    instance_id: &ContractInstanceId,
) -> tokio::sync::OwnedMutexGuard<()> {
    let mutex = CONTRACT_REFRESH_LOCKS
        .entry(*instance_id)
        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
        .clone();
    mutex.lock_owned().await
}

/// Take `CONTRACT_CACHE_LOCKS[instance_id]` without waiting. `None` means an
/// unpack for that contract is in flight, which is exactly when the eviction
/// sweep must leave the entry alone.
fn try_acquire_cache_lock(
    instance_id: &ContractInstanceId,
) -> Option<tokio::sync::OwnedMutexGuard<()>> {
    let mutex = CONTRACT_CACHE_LOCKS
        .entry(*instance_id)
        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
        .clone();
    mutex.try_lock_owned().ok()
}

// =============================================================================
// Webapp cache size bound (LRU)
// =============================================================================

/// Total on-disk size the extracted webapp cache may occupy before the
/// least-recently-used entries are evicted.
///
/// The cache is a pure, recomputable artifact — an unpacked web archive that
/// `unpack_if_stale` re-extracts whenever the contract state hash changes — so
/// a miss costs one re-unpack of state the node already has, and nothing else
/// in the node depends on an entry existing. Until this bound existed the
/// directory had per-contract staleness replacement but no global limit, so it
/// grew by one entry for every webapp the user ever opened and never shrank
/// (measured: 325 MB / 61 entries on one peer, 1.2 GB / 82 entries on another,
/// with entries up to six months untouched).
///
/// 64 MiB is chosen against the observed per-entry distribution: a typical
/// unpacked webapp is a few hundred KB to a few MB, so the budget keeps roughly
/// the last 15-30 distinct webapps the user actually browsed — far more than a
/// browsing session touches — while cutting >90% of the observed footprint.
/// These bytes are additionally invisible to the node's disk accounting: the
/// cache lives under the XDG *cache* dir, whereas `ring/hosting/disk_usage.rs`
/// only walks `contracts_dir` + `wasmtime_cache_dir`, so nothing else bounds it.
const WEBAPP_CACHE_MAX_BYTES: u64 = 64 * 1024 * 1024;

/// How long an entry is protected from eviction after this process last served
/// from it.
///
/// This is the in-flight-request guard: a request records an access before it
/// touches the cache, so a sweep running concurrently skips the entry instead of
/// competing with the request for it. It must therefore comfortably exceed the
/// 30s network fetch timeout in `ensure_contract_cached` (a request may spend
/// that long merely waiting on the node before reading the unpacked files).
///
/// The guard is a *strong preference*, not an interlock: the check and the
/// `remove_dir_all` are not atomic, and the record is per-process while the
/// directory is per-user, so an eviction racing a request remains possible in
/// principle (see [`enforce_webapp_cache_budget`]). What that costs is bounded:
/// on Unix an already-opened file survives unlinking, and `ServeFile` opens the
/// descriptor before streaming, so a slow download cannot be truncated
/// mid-flight; the worst case is a request that has not opened the file yet
/// falling back to a 404 or a refetch of a cache that is recomputable anyway.
///
/// Being time-bounded is load-bearing (see the cleanup-exemption rule in
/// AGENTS.md): the exemption always expires, so no entry can become permanently
/// un-evictable by being touched once.
const WEBAPP_CACHE_EVICTION_MIN_IDLE: Duration = Duration::from_secs(120);

/// How often an entry's on-disk last-used marker (the `{key}.hash` mtime) is
/// refreshed while it is being served.
///
/// Serving a webapp fans out many subresource requests, so refreshing the mtime
/// on every one would add a filesystem timestamp update per request for no
/// benefit. Throttling to one refresh per contract per 5 minutes keeps the
/// on-disk LRU signal accurate to within 5 minutes, which is far finer than the
/// horizon eviction actually discriminates on (hours to months).
const WEBAPP_CACHE_ACCESS_TOUCH_INTERVAL: Duration = Duration::from_secs(300);

/// Most entries one sweep will delete before giving up and leaving the rest to
/// the next one.
///
/// Steady state evicts zero or one entry per unpack, so this never binds there.
/// It exists for the ONE-OFF case this whole change is motivated by: the first
/// sweep on a node that upgrades with an unbounded legacy cache. The 1.2 GB /
/// 82-entry directory measured on a real peer would otherwise do ~78
/// `remove_dir_all`s inline before the shell page returns — a visible stall on
/// the first webapp load after an upgrade. Capped, that backlog drains over the
/// next handful of unpacks (plus the debounced reconcile sweep) instead of
/// landing on one request.
///
/// Note this bounds the *deletion* half only. The directory walk that precedes
/// it is proportional to the tree and is not capped — it is the price of
/// knowing the size at all, it runs on `spawn_blocking` rather than the
/// reactor, and it shrinks with the cache over the first few sweeps.
const WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP: usize = 8;

/// Minimum interval between budget sweeps that were triggered by a *reconcile*
/// rather than by an unpack.
///
/// An unpack is the only event that grows the cache, so it always sweeps. But a
/// node upgrading with an already-oversized cache may reconcile contracts whose
/// state hash never changes and therefore never unpack, so the reconcile path
/// also gets a chance to sweep — debounced, because unlike an unpack it is not
/// itself expensive and would otherwise pay for a directory walk on every
/// 30-second refresh of every contract.
const WEBAPP_CACHE_SWEEP_INTERVAL: Duration = Duration::from_secs(600);

/// Per-contract record of how recently this process served from the entry.
#[derive(Clone, Copy)]
struct CacheAccess {
    /// Last time any handler served (or attempted to serve) this contract.
    /// Drives the in-flight eviction guard.
    last_access: Instant,
    /// Last time `last_access` was mirrored onto the `{key}.hash` mtime.
    /// Drives the touch throttle only.
    last_persisted: Instant,
}

/// In-memory last-access record, mirrored to disk at
/// `WEBAPP_CACHE_ACCESS_TOUCH_INTERVAL` granularity.
///
/// Bounded by the number of entries actually on disk, which
/// `WEBAPP_CACHE_MAX_BYTES` bounds in turn: it is only ever written where the
/// cache entry is known to exist (a warm `{key}.hash`, or a fetch that just
/// populated one), and the sweep drops the record when it evicts the entry.
/// That gating is load-bearing, not incidental — `variable_content` is reachable
/// unauthenticated with an arbitrary key, so recording an access before the
/// #3945 presence gate has run would hand an attacker an unbounded per-key map
/// (see the per-key-collection rule in `.claude/rules/code-style.md`).
static WEBAPP_CACHE_ACCESS: LazyLock<DashMap<ContractInstanceId, CacheAccess>> =
    LazyLock::new(DashMap::new);

/// What caused a budget sweep to be considered.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SweepTrigger {
    /// The cache just grew — always sweep.
    Unpack,
    /// The cache was reconciled but not rewritten — sweep at most once per
    /// `WEBAPP_CACHE_SWEEP_INTERVAL`.
    Reconcile,
}

/// Sweep bookkeeping for one cache directory: when the last sweep ran (the
/// reconcile debounce) and whether one is running right now.
#[derive(Default, Debug)]
struct SweepState {
    last_sweep: Option<Instant>,
    in_progress: bool,
}

/// Where the extracted webapp cache lives, how large it may grow, and how often
/// that bound is enforced.
///
/// **Injected from the node's configuration, never read from a global.** Every
/// path the handlers touch — `<key>/`, `<key>.hash`, and the sweep root — is
/// derived from `root`, which the router builds once from
/// `WebsocketApiConfig::webapp_cache_dir` and hands to every handler through the
/// axum `State`.
///
/// That injection is a safety property, not a convenience, and it took two
/// attempts to get right. The sweep DELETES, and several tests drive
/// `unpack_if_stale` end to end, so a root read from a process global made
/// `cargo test -p freenet` evict the developer's real
/// `~/.cache/freenet/webapp_cache` down to the production budget — and, because
/// the in-flight guards are per-process while the directory is per-user, it
/// could evict entries a node running as the same user was actively serving.
/// The first fix gated a temp-dir redirect on `#[cfg(test)]`, which covers unit
/// tests only: `cfg(test)` is false when an integration test links the lib as an
/// ordinary dependency, so `tests/playwright_shell.rs` — which boots a real node
/// and fetches a shell page on a plain `cargo test` — still swept the real cache.
/// Threading the root has no such blind spot: a caller that does not supply one
/// does not compile.
///
/// Because the root comes from the node's config, a test node pointed at a
/// `tempfile::tempdir()` data dir gets an isolated cache for free, and two nodes
/// run by the same user no longer share one directory.
#[derive(Clone, Debug)]
pub(crate) struct WebappCache {
    root: PathBuf,
    max_bytes: u64,
    sweep: Arc<parking_lot::Mutex<SweepState>>,
}

impl WebappCache {
    /// The cache a node serves from, bounded by [`WEBAPP_CACHE_MAX_BYTES`].
    ///
    /// One instance per server, cloned into the router state, so the sweep
    /// debounce and in-progress flag are shared across that node's requests.
    ///
    /// Creates the directory and names it in the log, once, here: this is where
    /// the cache takes ownership of a path it will DELETE from, and nothing else
    /// in the node identifies that path. An operator asking "what is removing
    /// files from here" or "where did this disk go" otherwise has nowhere to
    /// look, and a sweeper should say which directory it sweeps.
    ///
    /// Creating it eagerly also converts the two silent-misconfiguration shapes
    /// into a startup warning: a root that exists as a FILE, or one that cannot
    /// be created (permissions, read-only mount). Either leaves every unpack
    /// failing and the sweep scanning nothing, i.e. a cache that never populates
    /// and a bound that never runs, with no error surfaced anywhere because both
    /// paths are best-effort by design. That failure is not fatal and must not
    /// be, since the node serves everything except web contracts perfectly well,
    /// so this warns and carries on rather than refusing to start.
    pub(crate) fn with_root(root: PathBuf) -> Self {
        match std::fs::create_dir_all(&root) {
            Ok(()) => tracing::info!(
                path = %root.display(),
                max_bytes = WEBAPP_CACHE_MAX_BYTES,
                "webapp cache: unpacked web contracts are cached here; \
                 least-recently-used entries are DELETED from here to hold the \
                 directory under its size bound"
            ),
            Err(err) => tracing::warn!(
                path = %root.display(),
                "webapp cache: cannot create the cache directory ({err}); web \
                 contracts will fail to unpack and the size bound will not run. \
                 Check that the path is a directory and is writable, or point \
                 the node elsewhere with FREENET_WEBAPP_CACHE_DIR."
            ),
        }
        Self {
            root,
            max_bytes: WEBAPP_CACHE_MAX_BYTES,
            sweep: Arc::new(parking_lot::Mutex::new(SweepState::default())),
        }
    }

    /// The directory this cache owns — i.e. the one its sweep deletes from.
    #[cfg(test)]
    pub(crate) fn root(&self) -> &Path {
        &self.root
    }

    /// Directory the contract's web archive is unpacked into.
    fn entry_dir(&self, instance_id: &ContractInstanceId) -> PathBuf {
        self.root.join(instance_id.encode())
    }

    /// The `{key}.hash` sentinel: holds the unpacked state's hash, doubles as
    /// the "cache is populated" marker and as the LRU last-used timestamp.
    fn hash_path(&self, instance_id: &ContractInstanceId) -> PathBuf {
        self.root.join(format!("{}.hash", instance_id.encode()))
    }
}

/// Clears `in_progress` however the sweep ends, so a panic mid-sweep cannot
/// wedge the flag on and suppress every future sweep.
struct SweepInProgress(Arc<parking_lot::Mutex<SweepState>>);

impl Drop for SweepInProgress {
    fn drop(&mut self) {
        self.0.lock().in_progress = false;
    }
}

/// One `<instance_id>` entry of the webapp cache as seen by a sweep.
struct WebappCacheEntry {
    instance_id: ContractInstanceId,
    /// The name as it appears on disk — the directory name and the `{key}.hash`
    /// stem, which `scan_webapp_cache` has verified round-trips through
    /// `ContractInstanceId`.
    encoded: String,
    /// Unpacked tree plus the sentinel hash file.
    bytes: u64,
    /// Last-used proxy — see `scan_webapp_cache`.
    last_used: SystemTime,
}

/// Outcome of one sweep. Returned rather than logged-only so tests can assert
/// on the decisions instead of on filesystem side effects alone.
#[derive(Default, Debug)]
struct WebappCacheSweep {
    total_before: u64,
    bytes_freed: u64,
    evicted: Vec<ContractInstanceId>,
}

/// Record that `instance_id` is being served right now, and report whether the
/// on-disk last-used marker is due for a refresh.
///
/// Pure in-memory and synchronous: this runs on every request, so it must not
/// touch the filesystem. The (rare) disk refresh is the caller's job.
fn record_cache_access(instance_id: ContractInstanceId) -> bool {
    use dashmap::mapref::entry::Entry;

    let now = Instant::now();
    match WEBAPP_CACHE_ACCESS.entry(instance_id) {
        Entry::Occupied(mut occupied) => {
            let access = occupied.get_mut();
            access.last_access = now;
            if now.duration_since(access.last_persisted) >= WEBAPP_CACHE_ACCESS_TOUCH_INTERVAL {
                access.last_persisted = now;
                true
            } else {
                false
            }
        }
        Entry::Vacant(vacant) => {
            vacant.insert(CacheAccess {
                last_access: now,
                last_persisted: now,
            });
            true
        }
    }
}

/// True while `instance_id` is inside its post-access eviction grace window.
fn accessed_recently(instance_id: &ContractInstanceId) -> bool {
    WEBAPP_CACHE_ACCESS
        .get(instance_id)
        .map(|access| access.last_access.elapsed() < WEBAPP_CACHE_EVICTION_MIN_IDLE)
        .unwrap_or(false)
}

/// Mirror the last-access time onto the `{key}.hash` mtime, which is what
/// survives a restart and is what the sweep ranks on.
///
/// A timestamp-only update (`filetime::set_file_mtime`, which opens the file and
/// calls `futimens`) rather than a rewrite: the sentinel's *contents* are the
/// state hash that `unpack_if_stale` compares against, and rewriting them would
/// race a concurrent unpack. Best effort — a missing file (cold cache) or a
/// read-only cache dir must never fail a user request.
async fn persist_cache_access_marker(hash_path: PathBuf) {
    let result = tokio::task::spawn_blocking(move || {
        filetime::set_file_mtime(&hash_path, filetime::FileTime::now())
    })
    .await;
    match result {
        Ok(Ok(())) => {}
        Ok(Err(err)) => debug!("webapp cache: could not refresh last-used marker: {err}"),
        Err(err) => debug!("webapp cache: last-used marker task failed: {err}"),
    }
}

/// Note that a handler is serving `instance_id`, refreshing the on-disk LRU
/// marker when due.
///
/// Only call this where the cache entry is known to exist — see the bounding
/// note on [`WEBAPP_CACHE_ACCESS`].
async fn note_cache_access(cache: &WebappCache, instance_id: ContractInstanceId) {
    if record_cache_access(instance_id) {
        persist_cache_access_marker(cache.hash_path(&instance_id)).await;
    }
}

/// Recursively sum the size of every regular file under `dir`. Unreadable
/// entries contribute 0 rather than erroring: an under-count only means the
/// sweep evicts less than it could, which is the safe direction for a cache
/// whose deletion is the destructive operation.
fn dir_size(dir: &Path) -> u64 {
    let mut total: u64 = 0;
    let mut stack = vec![dir.to_path_buf()];
    while let Some(path) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&path) else {
            continue;
        };
        for entry in entries.flatten() {
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if file_type.is_dir() {
                stack.push(entry.path());
            } else if file_type.is_file() {
                if let Ok(meta) = entry.metadata() {
                    total = total.saturating_add(meta.len());
                }
            }
        }
    }
    total
}

/// Enumerate the webapp cache under `root`, pairing each `<instance_id>`
/// directory with its `<instance_id>.hash` sentinel.
///
/// The last-used signal is the sentinel's mtime, which `note_cache_access`
/// refreshes while a contract is being served and which `unpack_if_stale`
/// rewrites on every re-extraction — so it tracks last USE, not creation.
/// Entries with no sentinel (or an unreadable one) fall back to the directory's
/// own mtime and finally to the epoch, i.e. they sort as the coldest.
///
/// Anything whose name is not a cache entry is ignored entirely: the sweep must
/// never count or delete files it does not own. `from_base58` alone is not a
/// sufficient filter — stdlib zero-pads a short decode instead of rejecting it
/// (`contract_interface/key.rs`), so ordinary names like `tmp`, `data` or
/// `assets` parse into well-formed but *wrong* ids. The name must therefore
/// round-trip: parse, re-encode, and match what is actually on disk. Without
/// that check the sweep would charge a stray directory's bytes to a phantom id,
/// try to delete a path that does not exist, treat the resulting `NotFound` as
/// success, and count bytes it never freed — stopping early, staying over
/// budget, and reporting evictions that deleted nothing.
///
/// Blocking — call from `spawn_blocking`.
fn scan_webapp_cache(root: &Path) -> Vec<WebappCacheEntry> {
    /// Parse a cache-entry name, rejecting anything that does not re-encode to
    /// itself. See the round-trip note on `scan_webapp_cache`.
    fn parse_entry_name(name: &str) -> Option<ContractInstanceId> {
        let instance_id = ContractInstanceId::from_base58(name).ok()?;
        (instance_id.encode() == name).then_some(instance_id)
    }

    // (bytes, sentinel mtime, directory mtime)
    let mut by_id: HashMap<ContractInstanceId, (u64, Option<SystemTime>, Option<SystemTime>)> =
        HashMap::new();
    let Ok(dir_entries) = std::fs::read_dir(root) else {
        return Vec::new();
    };
    for dir_entry in dir_entries.flatten() {
        let Ok(file_type) = dir_entry.file_type() else {
            continue;
        };
        let file_name = dir_entry.file_name();
        let Some(name) = file_name.to_str() else {
            continue;
        };
        if file_type.is_dir() {
            let Some(instance_id) = parse_entry_name(name) else {
                continue;
            };
            let slot = by_id.entry(instance_id).or_insert((0, None, None));
            slot.0 = slot.0.saturating_add(dir_size(&dir_entry.path()));
            slot.2 = dir_entry
                .metadata()
                .ok()
                .and_then(|meta| meta.modified().ok());
        } else if file_type.is_file() {
            let Some(stem) = name.strip_suffix(".hash") else {
                continue;
            };
            let Some(instance_id) = parse_entry_name(stem) else {
                continue;
            };
            let meta = dir_entry.metadata().ok();
            let slot = by_id.entry(instance_id).or_insert((0, None, None));
            slot.0 = slot
                .0
                .saturating_add(meta.as_ref().map(|m| m.len()).unwrap_or(0));
            slot.1 = meta.and_then(|meta| meta.modified().ok());
        }
    }

    by_id
        .into_iter()
        .map(
            |(instance_id, (bytes, hash_mtime, dir_mtime))| WebappCacheEntry {
                // Equal to the on-disk name by construction: `parse_entry_name`
                // admitted the id only because the two already matched.
                encoded: instance_id.encode(),
                instance_id,
                bytes,
                last_used: hash_mtime.or(dir_mtime).unwrap_or(SystemTime::UNIX_EPOCH),
            },
        )
        .collect()
}

/// Delete one cache entry: sentinel first, then the unpacked tree.
///
/// The order is load-bearing. A directory with no `{key}.hash` reads as a COLD
/// cache and is simply re-fetched; a `{key}.hash` with no directory reads as a
/// WARM cache and would serve 404s until the contract's state happened to
/// change. So an interrupted eviction must leave the first shape, never the
/// second — and if the sentinel cannot be removed we leave the entry entirely
/// alone rather than create the second shape deliberately.
async fn remove_cache_entry(root: &Path, encoded: &str) -> std::io::Result<()> {
    match tokio::fs::remove_file(root.join(format!("{encoded}.hash"))).await {
        Ok(()) => {}
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err),
    }
    match tokio::fs::remove_dir_all(root.join(encoded)).await {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err),
    }
}

/// Evict least-recently-used entries until `cache` fits in its budget.
///
/// `in_use` is the contract whose request triggered the sweep; it is never a
/// victim of its own sweep. Two further guards steer eviction away from live
/// requests: an entry served within `WEBAPP_CACHE_EVICTION_MIN_IDLE` is skipped,
/// and an entry whose `CONTRACT_CACHE_LOCKS` mutex is held (an unpack is in
/// flight) is skipped via `try_lock`.
///
/// Those guards are a strong preference, not an interlock — the check and the
/// `remove_dir_all` are not atomic, and both guards are per-process while the
/// directory is per-user, so a request in another process (or one that slipped
/// between the check and the delete) can still lose its entry. That is
/// survivable rather than merely unlikely: the cache is recomputable, and on
/// Unix an already-opened file survives unlinking, so an in-flight `ServeFile`
/// stream completes and the worst case is a 404 or a refetch. See
/// `WEBAPP_CACHE_EVICTION_MIN_IDLE`.
///
/// The bound is best-effort in the other direction too: if every oversized entry
/// is protected, if a single webapp is itself larger than the budget, or if the
/// overage needs more than `WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP` deletions, the
/// sweep leaves the cache over budget and the next one retries. It never deletes
/// a protected entry to hit the number, and a failure to delete one entry never
/// aborts the sweep or propagates to the request.
///
/// # Cost
///
/// Awaited inline by the caller, so it is on the request path. In steady state
/// that is a directory walk plus at most one deletion, which is small change
/// next to the `remove_dir_all` + `unpack` that triggered it. The one expensive
/// case is the first sweep after upgrading a node with an unbounded legacy
/// cache: the walk is proportional to the whole tree (on `spawn_blocking`, so
/// it does not block the reactor) and the deletions are capped — see
/// [`WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP`].
async fn enforce_webapp_cache_budget(
    cache: &WebappCache,
    in_use: Option<ContractInstanceId>,
) -> WebappCacheSweep {
    let root = cache.root.clone();
    let max_bytes = cache.max_bytes;
    let scan_root = root.clone();
    let entries = match tokio::task::spawn_blocking(move || scan_webapp_cache(&scan_root)).await {
        Ok(entries) => entries,
        Err(err) => {
            tracing::warn!("webapp cache: size scan failed, skipping sweep: {err}");
            return WebappCacheSweep::default();
        }
    };

    let total: u64 = entries
        .iter()
        .fold(0u64, |acc, entry| acc.saturating_add(entry.bytes));
    let mut sweep = WebappCacheSweep {
        total_before: total,
        ..Default::default()
    };
    if total <= max_bytes {
        return sweep;
    }

    let mut entries = entries;
    // Oldest use first; base58 key as a deterministic tiebreak so two entries
    // sharing an mtime (common at 1s filesystem granularity) always evict in
    // the same order.
    entries.sort_by(|a, b| {
        a.last_used
            .cmp(&b.last_used)
            .then_with(|| a.encoded.cmp(&b.encoded))
    });

    let mut live = total;
    for entry in &entries {
        if live <= max_bytes || sweep.evicted.len() >= WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP {
            break;
        }
        if Some(entry.instance_id) == in_use || accessed_recently(&entry.instance_id) {
            continue;
        }
        let Some(guard) = try_acquire_cache_lock(&entry.instance_id) else {
            continue;
        };
        match remove_cache_entry(&root, &entry.encoded).await {
            Ok(()) => {
                live = live.saturating_sub(entry.bytes);
                sweep.bytes_freed = sweep.bytes_freed.saturating_add(entry.bytes);
                sweep.evicted.push(entry.instance_id);
                WEBAPP_CACHE_ACCESS.remove(&entry.instance_id);
                // Drop the reconcile timer too: `refresh_cache_if_due` returns
                // early on a fresh timer alone, so an evicted contract with a
                // live timer would serve 404s from the now-empty directory
                // until the TTL expired.
                CONTRACT_CACHE_REFRESH.remove(&entry.instance_id);
            }
            Err(err) => {
                tracing::warn!(
                    "webapp cache: could not evict {}: {err}",
                    entry.encoded.as_str()
                );
            }
        }
        drop(guard);
    }

    if !sweep.evicted.is_empty() {
        tracing::info!(
            evicted = sweep.evicted.len(),
            freed_bytes = sweep.bytes_freed,
            total_before = sweep.total_before,
            still_over_budget = live > max_bytes,
            "webapp cache: evicted least-recently-used entries to fit the size bound"
        );
    } else if live > max_bytes {
        debug!(
            total_bytes = total,
            max_bytes, "webapp cache: over budget but every entry is in use"
        );
    }
    sweep
}

/// Whether a sweep with this `trigger` is due.
///
/// An unpack is the only thing that grows the cache, so it always sweeps.
/// A reconcile rewrote nothing, so it sweeps at most once per
/// `WEBAPP_CACHE_SWEEP_INTERVAL` — otherwise every contract's 30-second refresh
/// would pay for a directory walk.
fn sweep_is_due(trigger: SweepTrigger, last_sweep: Option<Instant>, now: Instant) -> bool {
    match trigger {
        SweepTrigger::Unpack => true,
        SweepTrigger::Reconcile => {
            last_sweep.is_none_or(|prev| now.duration_since(prev) >= WEBAPP_CACHE_SWEEP_INTERVAL)
        }
    }
}

/// Run a budget sweep if `trigger` calls for one and no sweep is already
/// running.
///
/// The in-progress gate is not just an optimisation. Each sweep takes its own
/// `live` snapshot and deletes until *it* has freed the deficit, so N concurrent
/// unpacks would each evict a full deficit's worth and drive the cache well
/// below budget, over-reporting `bytes_freed` as they went. One sweep at a time
/// makes the eviction count match the actual overage.
async fn maybe_enforce_webapp_cache_budget(
    cache: &WebappCache,
    in_use: ContractInstanceId,
    trigger: SweepTrigger,
) {
    let _in_progress = {
        // Scoped so the (sync) lock is released before the await below.
        let mut state = cache.sweep.lock();
        let now = Instant::now();
        if state.in_progress || !sweep_is_due(trigger, state.last_sweep, now) {
            return;
        }
        state.in_progress = true;
        state.last_sweep = Some(now);
        SweepInProgress(Arc::clone(&cache.sweep))
    };
    enforce_webapp_cache_budget(cache, Some(in_use)).await;
}

/// True if the contract was reconciled against the network within the last
/// `CONTRACT_CACHE_REFRESH_TTL`. A missing timer reads as not-fresh.
fn cache_reconciled_recently(instance_id: &ContractInstanceId) -> bool {
    CONTRACT_CACHE_REFRESH
        .get(instance_id)
        .map(|last| last.elapsed() < CONTRACT_CACHE_REFRESH_TTL)
        .unwrap_or(false)
}

/// Whether the local node already has `instance_id` in its contract store /
/// hosting cache, or holds an active subscription to it.
///
/// # Why this gate exists (DoS amplification — #3945)
///
/// #3942 made `variable_content` issue a cold-cache network GET so a
/// subresource (`<img src>`) pointing at a contract resolves instead of
/// 404ing (#3940). That widened the attack surface: an unauthenticated
/// request to `/v1/contract/web/<KEY>/...` for a *random* 32-byte `KEY`
/// no longer 404s from the local cache check — it triggers a full network
/// GET (fan-out to remote peers) + unpack. Subresource URLs are
/// machine-fetchable, so an attacker can spray random keys and force the
/// node to issue outbound GETs it would never otherwise issue. Per-key rate
/// is bounded by the 30s fetch timeout but the parallel fan-out is not.
///
/// Gating the cold fetch on local-presence closes that vector while keeping
/// the real #3940 scenario working. The #3940 case is a cross-contract
/// `<img src="…/web/X/img.png">`: the user visits webapp Delta, whose page
/// embeds a subresource from a *different* contract X. The user has NOT
/// visited X's root, so X is NOT in the node's application-subscription set.
/// But the node will have **stored** X in its hosting cache the first time
/// any client (this one or another, on a shared gateway) fetched it — and
/// that store presence is exactly the bar #3945 option 2 names ("already
/// known to the local contract store, pinned/subscribed"). So the gate keys
/// off store/hosting presence, which covers the cross-contract subresource
/// case, while a random never-seen key — present in neither the store nor
/// the subscription set — gets the pre-#3942 404.
///
/// # Signal & mechanism
///
/// The HTTP layer has no direct handle on `op_manager`/`ring`; it only
/// reaches the node over the existing `ClientConnection` channel. So we
/// reuse the same transient-connection pattern as `ensure_contract_cached`
/// and ask the node the *local* `NodeQuery::NodeDiagnostics` query, scoped
/// to this one `instance_id`, with every flag off except `contract_keys`
/// (the store-presence answer) and `include_subscriptions`. This is a pure
/// ring/store lookup — `op_manager.ring.is_hosting_contract` /
/// `is_subscribed` / `hosting_contract_size` — with **no** network GET or
/// fan-out (see the `QueryNodeDiagnostics` handler in `p2p_protoc.rs`),
/// so the gate itself can never be the amplification vector it closes.
///
/// The contract is treated as known if either:
/// - it appears in `contract_states` (the node hosts/stores it, or holds an
///   active subscription lease — the `p2p_protoc.rs` handler only inserts an
///   entry when one of those is true), or
/// - it appears in `subscriptions` (the executor's application-subscription
///   set, populated when a client GETs it with `subscribe = true`).
///
/// On any error or timeout this returns `false` (fail closed): an attacker
/// must not be able to turn a transient node hiccup into an open fetch.
async fn is_locally_known(
    instance_id: ContractInstanceId,
    request_sender: &HttpClientApiRequest,
) -> bool {
    use freenet_stdlib::client_api::{NodeDiagnosticsConfig, NodeQuery, QueryResponse};

    let (response_sender, mut response_recv) = mpsc::unbounded_channel();
    if request_sender
        .send(ClientConnection::NewConnection {
            callbacks: response_sender,
            assigned_token: None,
        })
        .await
        .is_err()
    {
        return false;
    }
    // Fail closed if the node never assigns an id (e.g. it accepted the
    // connection but is wedged): bound the wait so a non-responsive node
    // can't pin the request task open under a spray of unknown keys.
    let client_id = match tokio::time::timeout(PRESENCE_QUERY_TIMEOUT, response_recv.recv()).await {
        Ok(Some(HostCallbackResult::NewId { id })) => id,
        _ => return false,
    };

    // Scope the diagnostics query to this one contract: only the store-presence
    // answer (`contract_keys`) and the application-subscription set
    // (`include_subscriptions`). Everything else is off so the node does the
    // minimum local work and returns no network/system data we don't read.
    let key = freenet_stdlib::prelude::ContractKey::from_id_and_code(
        instance_id,
        freenet_stdlib::prelude::CodeHash::new([0u8; 32]),
    );
    let config = NodeDiagnosticsConfig {
        include_node_info: false,
        include_network_info: false,
        include_subscriptions: true,
        contract_keys: vec![key],
        include_system_metrics: false,
        include_detailed_peer_info: false,
        include_subscriber_peer_ids: false,
    };

    let mut known = false;
    if request_sender
        .send(ClientConnection::Request {
            client_id,
            req: Box::new(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
                config,
            })),
            auth_token: None,
            origin_contract: None,
            // Internal node-query request: no delegate secrets, no user context.
            user_context: None,
            api_version: Default::default(),
        })
        .await
        .is_ok()
    {
        let recv_result = tokio::time::timeout(PRESENCE_QUERY_TIMEOUT, response_recv.recv()).await;
        if let Ok(Some(HostCallbackResult::Result {
            result: Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(info))),
            ..
        })) = recv_result
        {
            // `contract_states` keys are `ContractKey::Display`, which is the
            // base58 instance-id encoding (see stdlib `NodeDiagnosticsResponse`).
            let in_store = info.contract_states.contains_key(&instance_id.to_string());
            let subscribed = info
                .subscriptions
                .iter()
                .any(|sub| sub.contract_key == instance_id);
            known = in_store || subscribed;
        }
    }

    // Reap the transient client registration regardless of outcome.
    if let Err(err) = request_sender
        .send(ClientConnection::Request {
            client_id,
            req: Box::new(ClientRequest::Disconnect { cause: None }),
            auth_token: None,
            origin_contract: None,
            // Internal node-query request: no delegate secrets, no user context.
            user_context: None,
            api_version: Default::default(),
        })
        .await
    {
        tracing::warn!("is_locally_known: disconnect send failed: {err}");
    }

    known
}

/// Ensures the contract's webapp cache is populated and not stale before it is
/// served from disk.
///
/// Calls `ensure_contract_cached` when either:
/// - the cache is cold (no `{key}.hash` file on disk), or
/// - more than `CONTRACT_CACHE_REFRESH_TTL` has elapsed since the last
///   reconciliation for this contract.
///
/// For a **cold** cache the GET is additionally gated on the contract being
/// locally KNOWN (see `is_locally_known`): a cold cache for a contract the node
/// neither stores nor subscribes to is the random-key DoS amplification vector
/// #3942 opened, so this returns `Ok(())` without issuing the network GET and
/// the caller serves a 404 from the empty cache directory (the pre-#3942
/// behaviour). See #3945. A **warm-but-stale** refresh is NOT gated: a warm
/// on-disk cache already proves the node legitimately fetched this contract,
/// so refreshing it is not the amplification vector, and gating it would
/// silently regress the #3977 republish-pickup for a warm-but-unsubscribed
/// contract. The warm-and-fresh fast path never reaches either branch, so
/// steady-state requests pay nothing.
///
/// On a successful refresh the per-contract timer is reset. This is what makes
/// the `?__sandbox=1` and subresource paths pick up a republished bundle
/// without requiring a prior hit on the shell root. See #3977.
///
/// # Concurrency
///
/// A typical page load fans out several concurrent subresource requests. To
/// keep the "at most one network GET per contract per window" bound under that
/// fan-out, the refresh decision uses double-checked locking against the
/// dedicated per-contract `CONTRACT_REFRESH_LOCKS` mutex:
///
/// 1. A lock-free freshness check fast-paths the common warm-and-fresh case so
///    steady-state requests never contend on the lock.
/// 2. When a refresh looks due, the refresh lock is taken and the timer is
///    re-checked. The first holder fetches and updates the timer; every
///    follower that queued behind it observes the fresh timer and returns
///    without issuing its own GET. Without this gate, a burst of requests
///    arriving just after the TTL expiry would each fire a redundant GET.
///
/// The refresh lock is intentionally NOT `CONTRACT_CACHE_LOCKS`: the latter is
/// re-acquired inside `unpack_if_stale`, and `tokio`'s mutex is not reentrant,
/// so holding it across the GET would self-deadlock.
///
/// The refresh timer is only advanced on success, so a transient fetch failure
/// does not suppress the next request's retry. `ensure_contract_cached` skips
/// the disk rewrite when the state hash is unchanged (`unpack_if_stale`).
///
/// This is also where both cache-reading handlers (`variable_content` and
/// `serve_sandbox_content`) mark the entry as in use for the LRU size bound, so
/// the marking happens exactly once per request and only for entries that exist.
async fn refresh_cache_if_due(
    instance_id: ContractInstanceId,
    request_sender: &HttpClientApiRequest,
    cache: &WebappCache,
) -> Result<(), WebSocketApiError> {
    let hash_path = cache.hash_path(&instance_id);
    let cache_warm = tokio::fs::try_exists(&hash_path).await.unwrap_or(false);

    // The entry is about to be read, so mark it in use before anything else:
    // that both steers a concurrent budget sweep away from it for the duration
    // of this request and keeps its LRU marker current. Gated on `cache_warm`
    // because an arbitrary key reaching this handler has not yet cleared the
    // #3945 presence gate — see the bounding note on `WEBAPP_CACHE_ACCESS`.
    if cache_warm {
        note_cache_access(cache, instance_id).await;
    }

    // Fast path: a warm cache reconciled within the TTL needs no work and must
    // not contend on the refresh lock.
    if cache_warm && cache_reconciled_recently(&instance_id) {
        return Ok(());
    }

    // Slow path: refresh looks due. Serialize concurrent refreshers for this
    // contract so only the first issues a GET; the rest re-check below.
    let _guard = acquire_refresh_lock(&instance_id).await;
    // Re-check under the lock, and RE-STAT rather than trusting the timer
    // alone. The timer is per-process; the cache directory is per-USER, and the
    // documented multi-peer setup (peer-manager.sh) runs several nodes as one
    // user. Another node's budget sweep can therefore evict this entry at any
    // moment, and its `CONTRACT_CACHE_REFRESH.remove` — the in-process
    // mitigation — is invisible to us. Returning on a fresh timer alone would
    // then skip the refetch and serve 404s out of the emptied directory for the
    // rest of our TTL window. Requiring warm AND fresh also still covers the
    // in-process race this check was originally for: a concurrent refresher
    // that completed while we waited both populated the cache and recorded a
    // fresh timer, so it satisfies both halves.
    let still_warm = tokio::fs::try_exists(&hash_path).await.unwrap_or(false);
    if still_warm && cache_reconciled_recently(&instance_id) {
        return Ok(());
    }

    // DoS amplification gate (#3945) — COLD path only. A cold cache (no
    // `{key}.hash` on disk) for a contract the node has no local presence for
    // is exactly the random-key enumeration vector #3942 opened: skip the
    // network GET and let the caller serve a 404 from the empty cache
    // directory (the pre-#3942 behavior). A locally-KNOWN instance — the node
    // stores it (the #3940 cross-contract `<img src>` case, where X was stored
    // when the subresource was first loaded for some user) or subscribes to it
    // — falls through and fetches.
    //
    // The WARM-but-stale refresh is deliberately NOT gated: a warm on-disk
    // cache is itself proof the node legitimately fetched this contract
    // before, so a TTL-driven re-fetch of an already-cached bundle is not the
    // random-key amplification vector. Gating it would also silently break the
    // #3977 republish-pickup for a contract that is cached warm but currently
    // unsubscribed (it would serve the stale bundle instead of refreshing).
    // The gate reads `cache_warm || still_warm`: a sentinel seen at EITHER
    // observation is proof this node legitimately fetched the contract before,
    // which is the whole basis for exempting the warm path. Requiring both
    // would send a legitimate entry that another process just evicted through
    // the presence query, and requiring only the pre-lock snapshot would miss a
    // concurrent refresher that warmed the cache while we waited.
    if !(cache_warm || still_warm || is_locally_known(instance_id, request_sender).await) {
        return Ok(());
    }

    ensure_contract_cached(instance_id, request_sender, None, cache).await?;
    CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
    // The fetch populated the entry, so it now exists and is about to be read.
    note_cache_access(cache, instance_id).await;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
#[instrument(level = "debug", skip(request_sender, cache))]
pub(super) async fn contract_home(
    key: String,
    request_sender: HttpClientApiRequest,
    assigned_token: AuthToken,
    api_version: ApiVersion,
    query_string: Option<String>,
    sub_path: Option<&str>,
    hosted_mode: bool,
    cache: &WebappCache,
) -> Result<impl IntoResponse + use<>, WebSocketApiError> {
    let instance_id = ContractInstanceId::from_base58(&key).map_err(|err| {
        debug!("contract_home: Failed to parse contract key: {}", err);
        WebSocketApiError::InvalidParam {
            error_cause: format!("{err}"),
        }
    })?;

    // Register the assigned token with origin_contracts so subsequent
    // WebSocket connections from the shell iframe authenticate against
    // the correct contract identity, then fetch + unpack the contract.
    ensure_contract_cached(
        instance_id,
        &request_sender,
        Some((assigned_token.clone(), instance_id)),
        cache,
    )
    .await?;
    // The fetch populated the entry, so it now exists and is about to be read
    // by the iframe load that immediately follows. Marking it in use steers a
    // concurrent budget sweep away from it for that request.
    note_cache_access(cache, instance_id).await;
    // Record the reconciliation so the iframe load that immediately follows
    // (`?__sandbox=1`) and any subresource fetches reuse this fresh state
    // instead of issuing their own redundant GET within the TTL window.
    CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());

    // Return the shell page instead of the contract HTML directly.
    // The shell page wraps the contract in a sandboxed iframe for
    // origin isolation (GHSA-824h-7x5x-wfmf).
    match shell_page(
        &assigned_token,
        &key,
        api_version,
        query_string,
        sub_path,
        hosted_mode,
    ) {
        Ok(b) => Ok(b.into_response()),
        Err(err) => {
            tracing::error!("Failed to generate shell page: {err}");
            Err(WebSocketApiError::NodeError {
                error_cause: format!("Failed to generate shell page: {err}"),
            })
        }
    }
}

/// Fetches the contract from the network (or local storage) and unpacks
/// it into the webapp cache directory if the state hash differs from what
/// is already cached. Returns once the cache is guaranteed to be populated
/// for `instance_id`.
///
/// The optional `assigned_token` is forwarded to `ClientConnection::NewConnection`
/// so the caller can bind a freshly generated auth token to the instance for
/// later WebSocket authentication. Subresource fetches (images, JS, CSS) pass
/// `None` — they only need the cache side-effect.
///
/// # Why subresource requests need this
///
/// `variable_content` used to serve directly from the cache. If a browser
/// requested `/v1/contract/web/<KEY>/image.jpg` before any load of the
/// contract root (e.g. cross-contract `<img src>` from a different webapp),
/// the cache directory did not exist and the request 404'd. See #3940.
async fn ensure_contract_cached(
    instance_id: ContractInstanceId,
    request_sender: &HttpClientApiRequest,
    assigned_token: Option<(AuthToken, ContractInstanceId)>,
    cache: &WebappCache,
) -> Result<(), WebSocketApiError> {
    let (response_sender, mut response_recv) = mpsc::unbounded_channel();
    request_sender
        .send(ClientConnection::NewConnection {
            callbacks: response_sender,
            assigned_token,
        })
        .await
        .map_err(|err| WebSocketApiError::NodeError {
            error_cause: format!("{err}"),
        })?;
    let client_id = if let Some(HostCallbackResult::NewId { id }) = response_recv.recv().await {
        id
    } else {
        return Err(WebSocketApiError::NodeError {
            error_cause: "Couldn't register new client in the node".into(),
        });
    };
    request_sender
        .send(ClientConnection::Request {
            client_id,
            req: Box::new(
                ContractRequest::Get {
                    key: instance_id,
                    return_contract_code: true,
                    subscribe: true,
                    blocking_subscribe: false,
                }
                .into(),
            ),
            auth_token: None,
            origin_contract: None,
            // Internal node-query request: no delegate secrets, no user context.
            user_context: None,
            api_version: Default::default(),
        })
        .await
        .map_err(|err| WebSocketApiError::NodeError {
            error_cause: format!("{err}"),
        })?;

    let recv_result =
        tokio::time::timeout(std::time::Duration::from_secs(30), response_recv.recv()).await;
    let outcome = handle_get_response(instance_id, recv_result, cache).await;

    // Disconnect regardless of whether the fetch succeeded, so the node
    // can reap the transient client registration. A send failure means the
    // node is gone, which is already the important signal — we don't fail
    // the user's request over it, but we log at warn! so an operator sees
    // the trail if WebSocket connections subsequently hang.
    if let Err(err) = request_sender
        .send(ClientConnection::Request {
            client_id,
            req: Box::new(ClientRequest::Disconnect { cause: None }),
            auth_token: None,
            origin_contract: None,
            // Internal node-query request: no delegate secrets, no user context.
            user_context: None,
            api_version: Default::default(),
        })
        .await
    {
        tracing::warn!("ensure_contract_cached: disconnect send failed: {err}");
    }

    outcome
}

/// Processes the GetResponse from the node, unpacking into the cache if needed.
async fn handle_get_response(
    instance_id: ContractInstanceId,
    recv_result: Result<Option<HostCallbackResult>, tokio::time::error::Elapsed>,
    cache: &WebappCache,
) -> Result<(), WebSocketApiError> {
    match recv_result {
        // Transient: the 30s fetch wrapper elapsed before the node answered.
        // Use RequestError(Timeout) (not the dual-use OperationError) so the
        // HTTP layer can serve the retry page without also catching terminal
        // node-returned OperationErrors (e.g. banned contracts) — see #3472
        // and the `is_transient` matcher in errors.rs.
        Err(_) => Err(WebSocketApiError::AxumError {
            error: ErrorKind::RequestError(RequestError::Timeout),
        }),
        // Transient: the response channel closed (node restarting / shutting
        // down). ChannelClosed is unambiguously transient, unlike OperationError.
        Ok(None) => Err(WebSocketApiError::AxumError {
            error: ErrorKind::ChannelClosed,
        }),
        Ok(Some(HostCallbackResult::Result {
            result:
                Ok(HostResponse::ContractResponse(ContractResponse::GetResponse {
                    contract: Some(contract),
                    state,
                    ..
                })),
            ..
        })) => unpack_if_stale(&contract, state.as_ref(), cache).await,
        Ok(Some(HostCallbackResult::Result {
            result:
                Ok(HostResponse::ContractResponse(ContractResponse::GetResponse {
                    contract: None, ..
                })),
            ..
        })) => Err(WebSocketApiError::MissingContract { instance_id }),
        Ok(Some(HostCallbackResult::Result {
            result: Err(err), ..
        })) => {
            tracing::error!("error getting contract `{}`: {err}", instance_id.encode());
            Err(WebSocketApiError::AxumError {
                error: err.kind().clone(),
            })
        }
        Ok(other) => {
            tracing::error!("Unexpected node response: {other:?}");
            Err(WebSocketApiError::NodeError {
                error_cause: format!("Unexpected response from node: {other:?}"),
            })
        }
    }
}

/// Unpacks the contract's web archive into the cache directory if the stored
/// hash differs from the current state hash, or if there is no prior hash on
/// disk. The presence of the hash file is what `variable_content` uses as the
/// "cache is populated" signal — it is written last to make cache staleness
/// detection atomic.
///
/// Takes `CONTRACT_CACHE_LOCKS[instance_id]` for the duration of the mutation
/// so concurrent unpacks for the same contract serialize instead of racing
/// on `remove_dir_all` + `create_dir_all` + `unpack`. The hash is re-read
/// inside the lock — if a prior holder already wrote the current state, the
/// follower exits without repeating the work.
///
/// Both exits then run [`maybe_enforce_webapp_cache_budget`], which is what
/// keeps the cache from growing without bound (see [`WEBAPP_CACHE_MAX_BYTES`]).
async fn unpack_if_stale(
    contract: &ContractContainer,
    state_bytes: &[u8],
    cache: &WebappCache,
) -> Result<(), WebSocketApiError> {
    let contract_key = contract.key();
    let instance_id = *contract_key.id();
    let path = cache.entry_dir(&instance_id);
    let current_hash = hash_state(state_bytes);
    let hash_path = cache.hash_path(&instance_id);

    let _guard = acquire_cache_lock(&instance_id).await;

    // Re-read the hash under the lock. Concurrent `ensure_contract_cached`
    // callers for the same cold contract each arrive here with their own
    // GetResponse; the first to acquire the lock unpacks and writes the
    // hash, and any that queued behind it see the fresh hash here and
    // return without touching the filesystem again.
    let needs_update = match tokio::fs::read(&hash_path).await {
        Ok(stored_hash_bytes) if stored_hash_bytes.len() == 8 => {
            let stored_hash = u64::from_be_bytes(stored_hash_bytes.try_into().unwrap());
            stored_hash != current_hash
        }
        _ => true,
    };
    if !needs_update {
        // Nothing grew, but this is still the one code path every reconcile
        // reaches, so give the debounced sweep a chance: a node that upgrades
        // with an already-oversized cache may keep serving contracts whose
        // state hash never changes and would otherwise never sweep.
        drop(_guard);
        maybe_enforce_webapp_cache_budget(cache, instance_id, SweepTrigger::Reconcile).await;
        return Ok(());
    }

    debug!("State changed or not cached, unpacking webapp");
    let state = State::from(state_bytes);

    fn err(err: WebContractError, contract: &ContractContainer) -> WebSocketApiError {
        let key = contract.key();
        tracing::error!("{err}");
        WebSocketApiError::InvalidParam {
            error_cause: format!("failed unpacking contract: {key}"),
        }
    }

    // Clear existing cache if any; may not exist yet
    let _cleanup = tokio::fs::remove_dir_all(&path).await;
    tokio::fs::create_dir_all(&path)
        .await
        .map_err(|e| WebSocketApiError::NodeError {
            error_cause: format!("Failed to create cache dir: {e}"),
        })?;

    let mut web = WebApp::try_from(state.as_ref()).map_err(|e| err(e, contract))?;
    web.unpack(&path).map_err(|e| err(e, contract))?;

    // Store new hash LAST, so a partial unpack does not leave a stale
    // hash file that would make future requests skip the fetch.
    tokio::fs::write(&hash_path, current_hash.to_be_bytes())
        .await
        .map_err(|e| WebSocketApiError::NodeError {
            error_cause: format!("Failed to write state hash: {e}"),
        })?;

    // The unpack above is the only thing that grows the webapp cache, so it is
    // the natural (and cheapest) sweep trigger: the directory walk it costs is
    // small change next to the `remove_dir_all` + `unpack` just performed, and
    // no request that merely reads from a warm cache pays for it. Released the
    // per-contract lock first so the sweep's `try_lock` guard is only reporting
    // on OTHER contracts' in-flight unpacks.
    drop(_guard);
    maybe_enforce_webapp_cache_budget(cache, instance_id, SweepTrigger::Unpack).await;

    Ok(())
}

#[instrument(level = "debug", skip(request_sender, cache))]
pub(super) async fn variable_content(
    key: String,
    req_path: String,
    api_version: ApiVersion,
    request_sender: HttpClientApiRequest,
    cache: &WebappCache,
) -> Result<impl IntoResponse + use<>, Box<WebSocketApiError>> {
    debug!(
        "variable_content: Processing request for key: {}, path: {}",
        key, req_path
    );
    // compose the correct absolute path
    let instance_id =
        ContractInstanceId::from_base58(&key).map_err(|err| WebSocketApiError::InvalidParam {
            error_cause: format!("{err}"),
        })?;
    let base_path = cache.entry_dir(&instance_id);
    debug!("variable_content: Base path resolved to: {:?}", base_path);

    // Fetch + unpack the contract if its cache is cold OR stale. Without the
    // cold-cache fetch, any subresource request (e.g. an <img src> pointing at
    // this contract from a different webapp) would 404 because the cache is
    // only populated by the shell-root handler (`contract_home`). See #3940.
    // The TTL-gated staleness refresh additionally picks up a republished
    // bundle on this path without requiring a prior hit on the shell root.
    // See #3977.
    //
    // The cold-cache GET is gated on the contract being locally KNOWN (see
    // `refresh_cache_if_due` / `is_locally_known`): an unknown random key 404s
    // from the empty cache below instead of triggering an outbound network GET,
    // closing the DoS amplification #3942 opened. See #3945.
    refresh_cache_if_due(instance_id, &request_sender, cache)
        .await
        .map_err(Box::new)?;

    // Parse the full request path URI to extract the relative path using the v1 helper.
    let req_uri =
        req_path
            .parse::<axum::http::Uri>()
            .map_err(|err| WebSocketApiError::InvalidParam {
                error_cause: format!("Failed to parse request path as URI: {err}"),
            })?;
    debug!("variable_content: Parsed request URI: {:?}", req_uri);

    let relative_path = get_file_path(req_uri)?;
    debug!(
        "variable_content: Extracted relative path: {}",
        relative_path
    );

    let file_path = base_path.join(relative_path);
    debug!("variable_content: Full file path to serve: {:?}", file_path);
    debug!(
        "variable_content: Checking if file exists: {}",
        file_path.exists()
    );

    // For JavaScript files, rewrite root-relative asset paths just like we do for HTML.
    // Dioxus embeds paths like "/./assets/app_bg.wasm" inside the JS bundle, which browsers
    // normalize to "/assets/..." (root-relative), bypassing the contract web prefix.
    if file_path.extension().is_some_and(|ext| ext == "js") {
        let content = tokio::fs::read_to_string(&file_path).await.map_err(|err| {
            WebSocketApiError::NodeError {
                error_cause: format!("{err}"),
            }
        })?;
        let prefix = format!("/{}/contract/web/{key}/", api_version.prefix());
        let rewritten = content
            .replace("\"/./", &format!("\"{prefix}"))
            .replace("'/./", &format!("'{prefix}"));
        return Ok((
            [(axum::http::header::CONTENT_TYPE, "application/javascript")],
            rewritten,
        )
            .into_response());
    }

    // serve the file
    let mut serve_file = tower_http::services::fs::ServeFile::new(&file_path);
    let fake_req = axum::http::Request::new(axum::body::Body::empty());
    serve_file
        .try_call(fake_req)
        .await
        .map_err(|err| {
            WebSocketApiError::NodeError {
                error_cause: format!("{err}"),
            }
            .into()
        })
        .map(|r| r.into_response())
}

/// Escapes characters that are dangerous inside an HTML attribute value.
fn html_escape_attr(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#x27;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(ch),
        }
    }
    out
}

/// Validates a deep-link sub-path before it is interpolated into the
/// shell iframe's `data-src` URL (#3841).
///
/// The sub-path comes from the request URL's path component (axum's
/// `{*path}` wildcard), so a query string or fragment is normally split
/// off before it reaches us. This guard rejects:
///
/// - Characters that would break out of the URL path component: `?`
///   starts a query, `#` starts a fragment, `\` is treated as `/` by
///   browsers, and whitespace/control chars (incl. CR/LF) could corrupt
///   the attribute or — once HTML-unescaped by the browser — the
///   surrounding markup.
/// - A leading `/`, so the result stays relative to the contract web
///   prefix rather than becoming an absolute path.
/// - `.` / `..` path segments. This is the SECURITY-CRITICAL check:
///   unlike `sandbox_content_body` (which canonicalizes the on-disk file
///   against the contract cache dir), the dot-segments here would never
///   reach that layer. The browser normalizes `..` in a URL *before*
///   issuing the iframe request, so a `data-src` of
///   `/v1/contract/web/KEY/../OTHER/?__sandbox=1` would be requested as
///   `/v1/contract/web/OTHER/?__sandbox=1` — pointing the iframe at a
///   *different contract* under the current shell's token/origin. We
///   must therefore reject traversal segments here rather than relying
///   on later file-path canonicalization (Codex review, #3841).
fn sanitize_shell_sub_path(sub_path: &str) -> Result<String, WebSocketApiError> {
    if sub_path.starts_with('/') {
        return Err(WebSocketApiError::InvalidParam {
            error_cause: "deep-link sub-path must be relative".to_string(),
        });
    }
    if sub_path
        .chars()
        .any(|c| c.is_control() || c.is_whitespace() || matches!(c, '?' | '#' | '\\'))
    {
        return Err(WebSocketApiError::InvalidParam {
            error_cause: "deep-link sub-path contains an illegal character".to_string(),
        });
    }
    // Reject `.`/`..` segments. Split on `/` rather than using
    // `std::path::Component` so that a trailing-slash directory form like
    // `a/../` and an empty middle segment are both classified from the
    // raw URL text (no OS-specific path semantics). A browser collapses
    // these dot-segments client-side before requesting the iframe URL, so
    // they would escape the contract prefix without ever reaching the
    // on-disk canonicalization in `sandbox_content_body`.
    if sub_path.split('/').any(|seg| seg == "." || seg == "..") {
        return Err(WebSocketApiError::InvalidParam {
            error_cause: "deep-link sub-path must not contain '.' or '..' segments".to_string(),
        });
    }
    Ok(sub_path.to_string())
}

/// Generates the shell page HTML that wraps the contract in a sandboxed iframe.
///
/// The shell page holds the auth token and proxies WebSocket connections via
/// postMessage, providing origin isolation between contracts.
fn shell_page(
    auth_token: &AuthToken,
    contract_key: &str,
    api_version: ApiVersion,
    query_string: Option<String>,
    sub_path: Option<&str>,
    hosted_mode: bool,
) -> Result<impl IntoResponse, WebSocketApiError> {
    let version_prefix = api_version.prefix();
    // For a deep-link reload (#3841) the iframe must load the requested
    // sub-page, not the contract root, so the in-iframe webapp starts on
    // the right route. The sub-path is interpolated into the iframe's
    // `data-src`; `sanitize_shell_sub_path` rejects anything that could
    // break out of the URL's path component (`?`, `#`, control chars,
    // CRLF), and the whole `data-src` is HTML-escaped below as a second
    // layer of defence. Path traversal is additionally caught when the
    // iframe later requests `?__sandbox=1` (see `sandbox_content_body`).
    let sub_path = sub_path.map(sanitize_shell_sub_path).transpose()?;
    let base_path = match sub_path.as_deref() {
        Some(sp) => format!("/{version_prefix}/contract/web/{contract_key}/{sp}"),
        None => format!("/{version_prefix}/contract/web/{contract_key}/"),
    };

    // Build the iframe src URL: same path with __sandbox=1 plus any
    // original query params (e.g., ?invitation=...). `__sandbox` is the
    // server-interpreted routing flag and must come only from the line
    // we prepend here. `authToken` is the shell's credential — the
    // freshly-generated one is passed to `freenetBridge(authToken)`
    // below; a value forwarded from `query_string` would only arrive
    // via an attacker-controlled URL (pasted deep link or cross-contract
    // navigate-handler hop that preserved `resolved.search`), so strip
    // it to keep the iframe's `location.search` free of injected
    // credentials that a webapp reading `location.search` might pick up.
    let mut iframe_params = vec!["__sandbox=1".to_string()];
    if let Some(qs) = &query_string {
        for param in qs.split('&') {
            if param.is_empty() {
                continue;
            }
            // Strip any `__sandbox*` param (server-interpreted routing
            // flag) and the auth credential `authToken`. Both are
            // prefix-checked since a future refactor might add
            // variants like `__sandbox_debug` or `authToken2`.
            if param.starts_with("__sandbox") || param.starts_with("authToken") {
                continue;
            }
            iframe_params.push(param.to_string());
        }
    }
    let iframe_src_raw = format!("{}?{}", base_path, iframe_params.join("&"));
    // HTML-escape the iframe src to prevent XSS via crafted query parameters.
    // While browsers typically percent-encode special chars in URLs, we must not
    // rely on that for defense-in-depth.
    let iframe_src = html_escape_attr(&iframe_src_raw);

    // auth_token is base58 (alphanumeric only), safe for unescaped interpolation.
    let auth_token = auth_token.as_str();
    // Use an inline SVG data URI for the default favicon to avoid CORS errors
    // from cross-origin requests. Contracts can override this via the
    // __freenet_shell__ postMessage bridge (type: 'favicon').
    let favicon = format!(
        "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 471'>\
         <path d='{}' fill='%23007FFF' fill-rule='evenodd'/></svg>",
        super::home_page::RABBIT_SVG_PATH,
    );
    // Per-user durable token plumbing (P2-frontend of #4381). In hosted mode
    // the shell page mints/loads a durable per-user bearer secret from
    // `localStorage` and hands it to the bridge so the proxied WebSocket
    // upgrade carries `?userToken=<token>`. The shell is same-origin with the
    // node (so it CAN use localStorage); the sandboxed iframe is a different
    // origin and cannot. One token in localStorage = ONE identity per visitor
    // across every contract app on this node — the intended design.
    //
    // When hosted mode is OFF the shell is BEHAVIOURALLY identical to the
    // pre-#4381 shell: no token snippet, and the same single-argument
    // `freenetBridge(...)` call at the same site. Note this is behavioural, not
    // literal byte-equality — the always-injected SHELL_BRIDGE_JS itself gained
    // a `userToken` argument and an inert, undefined-guarded `if (userToken)`
    // branch that never fires when the bridge is called with one argument. The
    // token is generated client-side from `crypto.getRandomValues` and is NEVER
    // derived from any request input, so there is no injection vector.
    let (user_token_script, bridge_call) = if hosted_mode {
        (
            format!("<script>\n{SHELL_USER_TOKEN_JS}\n</script>\n"),
            // Third arg `true` puts the bridge in hosted mode so it can fail
            // closed when it has no per-user token (http, or storage failure) —
            // see the hostedNoToken branch in SHELL_BRIDGE_JS.
            format!("freenetBridge(\"{auth_token}\", __freenet_user_token, true);"),
        )
    } else {
        // Non-hosted: the original 1-arg call. `hostedMode` is undefined, so the
        // fail-closed branch never triggers and behavior is unchanged.
        (String::new(), format!("freenetBridge(\"{auth_token}\");"))
    };

    // Hosted-mode "shell chrome": a thin, host-controlled bar rendered OUTSIDE
    // the sandboxed app iframe. It is the only place a per-user-data action
    // (export to your own peer) can live — the durable user token is held by
    // the shell and must never reach the sandbox — and the only place the
    // "this is a hosted proxy, not private" disclosure cannot be hidden or
    // spoofed by the contract app. Empty (and the layout unchanged) when hosted
    // mode is off. The export control is a placeholder until the node-side
    // export endpoint lands (P3 `secrets export` over HTTP, scoped to the
    // connection's user token).
    let (hosted_styles, hosted_bar) = if hosted_mode {
        (
            format!("\n<style>{HOSTED_BAR_STYLES}</style>"),
            format!("{HOSTED_BAR_HTML}\n<script>{HOSTED_BAR_JS}</script>"),
        )
    } else {
        (String::new(), String::new())
    };
    // NOTE: every placeholder must be passed as an explicit `name = name`
    // argument. `format!` cannot implicitly capture `{ident}` variables when the
    // format string is produced by a macro (`include_str!`) rather than written
    // as a string literal.
    let html = format!(
        include_str!("path_handlers/assets/shell.html"),
        favicon = favicon,
        hosted_styles = hosted_styles,
        hosted_bar = hosted_bar,
        iframe_src = iframe_src,
        SHELL_BRIDGE_JS = SHELL_BRIDGE_JS,
        user_token_script = user_token_script,
        bridge_call = bridge_call,
    );

    Ok(Html(html))
}

/// Serves the contract's actual HTML content for display inside the sandboxed iframe.
///
/// This is called when the iframe requests `?__sandbox=1`. It reads the cached
/// contract HTML, rewrites asset paths, and injects the WebSocket shim that
/// routes connections through the shell page's postMessage bridge.
///
/// The `sub_path` parameter allows serving pages other than `index.html` for
/// multi-page websites. When `None`, defaults to `index.html`.
#[instrument(level = "debug", skip(request_sender))]
pub(super) async fn serve_sandbox_content(
    key: String,
    api_version: ApiVersion,
    sub_path: Option<&str>,
    request_sender: HttpClientApiRequest,
    cache: &WebappCache,
) -> Result<impl IntoResponse + use<>, WebSocketApiError> {
    let page = sub_path.unwrap_or("index.html");
    debug!("serve_sandbox_content: serving iframe content for key: {key}, page: {page}");
    let instance_id =
        ContractInstanceId::from_base58(&key).map_err(|err| WebSocketApiError::InvalidParam {
            error_cause: format!("{err}"),
        })?;

    // Reconcile the on-disk cache against current network state before serving.
    // Previously this path only checked `path.exists()` and served whatever was
    // already extracted, so a republished contract kept serving the old bundle
    // here until the shell root (`/`) was hit again. The TTL gate bounds the
    // network GET rate to at most one per contract per window. See #3977.
    refresh_cache_if_due(instance_id, &request_sender, cache).await?;

    let path = cache.entry_dir(&instance_id);
    if !path.exists() {
        return Err(WebSocketApiError::NodeError {
            error_cause: format!("Contract not cached yet: {key}"),
        });
    }
    sandbox_content_body(&path, &key, api_version, page).await
}

/// Reads a contract HTML page, rewrites paths, and injects the WebSocket shim
/// and navigation interceptor.
async fn sandbox_content_body(
    path: &Path,
    contract_key: &str,
    api_version: ApiVersion,
    page: &str,
) -> Result<impl IntoResponse + use<>, WebSocketApiError> {
    // Sanitize the page path to prevent directory traversal and absolute paths.
    // Path::join with an absolute path replaces the base entirely on Unix,
    // so we must reject absolute paths, parent directory components, and root
    // directory components before joining.
    let normalized = Path::new(page);
    for component in normalized.components() {
        if matches!(
            component,
            std::path::Component::ParentDir | std::path::Component::RootDir
        ) {
            return Err(WebSocketApiError::InvalidParam {
                error_cause: "Path traversal not allowed".to_string(),
            });
        }
    }

    let mut web_path = path.join(page);
    // For directory-style paths, look for index.html inside the directory
    if web_path.is_dir() {
        web_path = web_path.join("index.html");
    }
    // Ensure the resolved path is still under the contract's cache directory
    let canonical_base = path
        .canonicalize()
        .map_err(|err| WebSocketApiError::NodeError {
            error_cause: format!("{err}"),
        })?;
    let canonical_file = web_path
        .canonicalize()
        .map_err(|err| WebSocketApiError::NodeError {
            error_cause: format!("Page not found: {page} ({err})"),
        })?;
    if !canonical_file.starts_with(&canonical_base) {
        return Err(WebSocketApiError::InvalidParam {
            error_cause: "Path traversal not allowed".to_string(),
        });
    }

    // Open the canonical path (not the user-supplied path) to prevent TOCTOU
    // attacks where a symlink could be swapped between canonicalize and open.
    let mut key_file =
        File::open(&canonical_file)
            .await
            .map_err(|err| WebSocketApiError::NodeError {
                error_cause: format!("{err}"),
            })?;
    let mut buf = vec![];
    key_file
        .read_to_end(&mut buf)
        .await
        .map_err(|err| WebSocketApiError::NodeError {
            error_cause: format!("{err}"),
        })?;
    let mut body = String::from_utf8(buf).map_err(|err| WebSocketApiError::NodeError {
        error_cause: format!("{err}"),
    })?;

    // Rewrite root-relative asset paths so they resolve under the contract's web prefix.
    // Dioxus generates paths like /./assets/app.js which browsers normalize to /assets/app.js
    // (root-relative). These bypass the /v1/contract/web/{key}/ prefix and 404.
    let version_prefix = api_version.prefix();
    let prefix = format!("/{version_prefix}/contract/web/{contract_key}/");
    body = body.replace("\"/./", &format!("\"{prefix}"));
    body = body.replace("'/./", &format!("'{prefix}"));

    // Inject the WebSocket shim and navigation interceptor before any other scripts.
    // The shim overrides window.WebSocket so that wasm-bindgen routes connections
    // through the shell page's bridge. The interceptor catches <a> clicks AND
    // overrides programmatic window.open, routing both through postMessage for
    // multi-page navigation without a sandbox-inheriting popup (#4645).
    let injected_scripts =
        format!("<script>{WEBSOCKET_SHIM_JS}</script><script>{NAVIGATION_INTERCEPTOR_JS}</script>");
    if let Some(pos) = body.find("</head>") {
        body.insert_str(pos, &injected_scripts);
    } else if let Some(pos) = body.find("<body") {
        body.insert_str(pos, &injected_scripts);
    } else {
        body = format!("{injected_scripts}{body}");
    }

    Ok(Html(body))
}

/// JavaScript that mints (or loads) the durable per-user token in hosted mode.
///
/// Injected into the shell page (P2-frontend of #4381) ONLY when the node runs
/// in hosted mode. The shell is same-origin with the node, so it can persist a
/// token in `localStorage`; the sandboxed iframe cannot. The token is a 32-byte
/// secret minted from `crypto.getRandomValues` (never from request input),
/// base58 (Bitcoin/bs58 alphabet) encoded, and reused across every visit and
/// every contract app on this node — one durable identity per visitor. The
/// bridge presents it on the proxied WebSocket upgrade as `?userToken=<token>`.
///
/// The server treats the token as an OPAQUE namespace key (it hashes the raw
/// string bytes — see [`crate::wasm_runtime::UserSecretContext::from_token`]),
/// so the encoding is a purely client-side, display-facing choice: older builds
/// stored a hex string and those tokens keep resolving to the same per-user
/// namespace, while new identities are base58 (shorter and less error-prone for
/// a user to copy or transcribe).
///
/// On a non-`https:` page the IIFE returns undefined BEFORE touching
/// `localStorage`, so the durable token is never loaded, minted, or transmitted
/// over a plaintext wire (client mirror of the backend REFUSE-PLAINTEXT-TOKEN
/// invariant — see `decide_user_token`).
///
/// `localStorage` access is wrapped in try/catch so that a browser with storage
/// disabled (private mode quirks, embedded webviews) degrades to an undefined
/// token rather than throwing before the bridge starts; an undefined token means
/// the bridge omits the `userToken` param and the backend treats the connection
/// as a local/anonymous one (see `decide_user_token`).
const SHELL_USER_TOKEN_JS: &str = include_str!("path_handlers/assets/shell_user_token.js");

/// Styles for the hosted-mode "shell chrome" bar (see `shell_page`). Rendered
/// only when hosted mode is on; the bar lives OUTSIDE the sandboxed app iframe.
const HOSTED_BAR_STYLES: &str = include_str!("path_handlers/assets/hosted_bar.css");

/// Markup for the hosted-mode bar: the always-visible "not private" disclosure
/// plus an Account popover with the access-key backup/restore, a "New ID"
/// control to start over with a fresh identity, and the export-to-your-own-peer
/// action. The access key is the per-user token, read from the shell-only
/// `__freenet_user_token` global — it never enters the sandboxed iframe.
const HOSTED_BAR_HTML: &str = include_str!("path_handlers/assets/hosted_bar.html");

/// Behavior for the hosted-mode bar (toggle popover, copy/restore the access
/// key, mint a fresh identity via "New ID", export data). Runs in the trusted
/// shell context.
const HOSTED_BAR_JS: &str = include_str!("path_handlers/assets/hosted_bar.js");

/// JavaScript for the shell page's postMessage bridge.
///
/// The bridge listens for WebSocket requests from the sandboxed iframe,
/// creates real WebSocket connections with the auth token injected, and
/// forwards messages in both directions. Only allows connections to the
/// local API server itself (same origin) to prevent the contract from using the
/// bridge as an open proxy to other localhost services.
///
/// `userToken` is the durable per-user bearer secret minted by
/// `SHELL_USER_TOKEN_JS` in hosted mode; it is `undefined` in non-hosted mode
/// (the bridge is then called with a single argument) and, when present, is
/// appended to the real WebSocket URL as `?userToken=<token>` so the node can
/// scope a per-user delegate-secret namespace (P2 of #4381).
const SHELL_BRIDGE_JS: &str = include_str!("path_handlers/assets/shell_bridge.js");

/// JavaScript WebSocket shim injected into the sandboxed iframe content.
///
/// Overrides `window.WebSocket` so that `web_sys::WebSocket::new()` (which
/// compiles to `new WebSocket(url)` via wasm-bindgen, resolving from global
/// scope at call time) is intercepted and routed through postMessage to the
/// shell page's bridge.
const WEBSOCKET_SHIM_JS: &str = include_str!("path_handlers/assets/websocket_shim.js");

/// JavaScript navigation interceptor injected into sandboxed iframe HTML pages.
///
/// Intercepts clicks on `<a>` elements and sends a postMessage to the shell
/// page, which either opens the URL in a new window (cross-origin) or updates
/// the iframe's `src` (same-origin). This enables multi-page website
/// navigation without weakening the sandbox (no `allow-top-navigation` nor
/// `allow-popups-to-escape-sandbox` needed).
///
/// Cross-origin links MUST be handled regardless of their `target` attribute,
/// because without `allow-popups-to-escape-sandbox` a `target="_blank"` click
/// would open a sandboxed popup with a null origin and the destination site
/// would see CORS failures. This was freenet/river#208: River webapps added
/// `target="_blank"` to every external link, the old interceptor skipped any
/// anchor with an explicit target, and the resulting sandboxed popups broke
/// logged-in pages like GitHub. The `open_url` bridge hands the URL to the
/// shell page, which opens it with a proper origin via `window.open`.
///
/// Same-origin links with an explicit non-`_self` target are left to the
/// browser so webapps that legitimately want multi-tab navigation within
/// their own contract still work.
///
/// The interceptor also overrides programmatic `window.open`: an app that opens
/// a new tab from its own JS bypasses the click/auxclick listeners, and on a
/// hosted node the resulting opaque-origin popup can't read the per-user access
/// key and dead-ends (freenet-core#4645). http(s) new-window opens are forwarded
/// through the same `open_url` bridge (real origin); `_self`/`_parent`/`_top`,
/// non-http(s) schemes, and loopback targets (which `open_url` refuses) fall back
/// to the native open. The returned WindowProxy is dropped (null), matching the
/// shell's `noopener` open.
const NAVIGATION_INTERCEPTOR_JS: &str =
    include_str!("path_handlers/assets/navigation_interceptor.js");

/// Extracts the relative file path from a contract web URI.
///
/// Strips the version and contract key prefix (e.g. `/v1/contract/web/{key}/`)
/// and returns the remaining path (e.g. `assets/app.js`).
fn get_file_path(uri: axum::http::Uri) -> Result<String, Box<WebSocketApiError>> {
    let path_str = uri.path();

    let remainder = if let Some(rem) = path_str.strip_prefix("/v1/contract/web/") {
        rem
    } else if let Some(rem) = path_str.strip_prefix("/v1/contract/") {
        rem
    } else if let Some(rem) = path_str.strip_prefix("/v2/contract/web/") {
        rem
    } else if let Some(rem) = path_str.strip_prefix("/v2/contract/") {
        rem
    } else {
        return Err(Box::new(WebSocketApiError::InvalidParam {
            error_cause: format!(
                "URI path '{path_str}' does not start with /v1/contract/ or /v2/contract/"
            ),
        }));
    };

    // remainder contains "{key}/{path}" or just "{key}"
    let file_path = match remainder.split_once('/') {
        Some((_key, path)) => path.to_string(),
        None => "".to_string(),
    };

    Ok(file_path)
}

fn hash_state(state: &[u8]) -> u64 {
    use std::hash::Hasher;
    let mut hasher = ahash::AHasher::default();
    hasher.write(state);
    hasher.finish()
}

/// The cache the handler tests seed and serve from: one per-process temp dir,
/// never the developer's real cache. Production builds its own from the node's
/// config, so nothing here can reach a real directory even by mistake.
#[cfg(test)]
fn test_webapp_cache() -> WebappCache {
    static TEST_CACHE: LazyLock<WebappCache> = LazyLock::new(|| {
        static ROOT: LazyLock<tempfile::TempDir> =
            LazyLock::new(|| tempfile::tempdir().expect("test webapp cache root"));
        WebappCache::with_root(ROOT.path().to_path_buf())
    });
    TEST_CACHE.clone()
}

/// Cache paths of [`test_webapp_cache`], so a test can seed an entry the
/// handlers will then find.
#[cfg(test)]
fn contract_web_path(instance_id: &ContractInstanceId) -> PathBuf {
    test_webapp_cache().entry_dir(instance_id)
}

#[cfg(test)]
fn state_hash_path(instance_id: &ContractInstanceId) -> PathBuf {
    test_webapp_cache().hash_path(instance_id)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Builds a pair (sender, receiver) suitable for capturing what
    /// `ensure_contract_cached` emits on the client-connection channel.
    fn request_channel() -> (
        HttpClientApiRequest,
        tokio::sync::mpsc::Receiver<ClientConnection>,
    ) {
        let (tx, rx) = tokio::sync::mpsc::channel::<ClientConnection>(4);
        (HttpClientApiRequest::from_sender(tx), rx)
    }

    /// Clears any webapp cache state for `instance_id` on disk.
    /// `contract_web_path` and `state_hash_path` resolve to the one per-process
    /// temp root of [`test_webapp_cache`], shared by every test in this module,
    /// so tests that exercise the cache must use unique keys AND scrub any stale
    /// filesystem residue from a prior run before asserting on behaviour.
    ///
    /// Also drops the in-memory `CONTRACT_CACHE_REFRESH` timer (process-global,
    /// like the on-disk cache) so a stale timer from a prior run doesn't flip a
    /// cold-cache assertion into a warm/fresh one.
    async fn clear_cache(instance_id: &ContractInstanceId) {
        tokio::fs::remove_file(state_hash_path(instance_id))
            .await
            .ok();
        tokio::fs::remove_dir_all(contract_web_path(instance_id))
            .await
            .ok();
        CONTRACT_CACHE_REFRESH.remove(instance_id);
        CONTRACT_REFRESH_LOCKS.remove(instance_id);
    }

    // =========================================================================
    // Webapp cache size bound (LRU eviction)
    //
    // These exercise `enforce_webapp_cache_budget` against a `TempDir` root
    // rather than the node's real configured root, so they neither depend on nor
    // disturb residue in the developer's XDG cache. The in-memory
    // side-tables (`WEBAPP_CACHE_ACCESS`, `CONTRACT_CACHE_LOCKS`,
    // `CONTRACT_CACHE_REFRESH`) ARE process-global, so every test uses its own
    // instance ids and `seed_cache_entry` scrubs them first.
    // =========================================================================

    /// Size of the `{key}.hash` sentinel `seed_cache_entry` writes; entry sizes
    /// the sweep sees are payload + this.
    const SENTINEL_BYTES: u64 = 8;

    /// A cache over `root` with an explicit budget and its own sweep state.
    ///
    /// Every cache test builds one of these. Nothing here may reach the default
    /// cache with the production budget: the sweep DELETES, so a test that swept
    /// `crate::config::default_webapp_cache_dir()` would evict the developer's
    /// real cache and, on a machine running a node as the same user, entries
    /// that node is serving. Constructed field-by-field rather than through
    /// `with_root` so a test budget can be set; `with_root`'s own behaviour is
    /// covered by `with_root_creates_the_cache_directory_it_will_sweep`.
    fn cache(root: &Path, max_bytes: u64) -> WebappCache {
        WebappCache {
            root: root.to_path_buf(),
            max_bytes,
            sweep: Arc::new(parking_lot::Mutex::new(SweepState::default())),
        }
    }

    /// Distinct instance id per (test, slot) pair, so process-global state from
    /// a sibling test can never protect or evict this test's entries.
    fn cache_id(test: u8, slot: u8) -> ContractInstanceId {
        let mut bytes = [0u8; 32];
        bytes[0] = 0xc0;
        bytes[1] = test;
        bytes[2] = slot;
        ContractInstanceId::new(bytes)
    }

    /// Materialize one cache entry of `payload` bytes under `root` whose
    /// last-used marker sits `age` in the past. Returns its total size as the
    /// sweep will account it.
    fn seed_cache_entry(
        root: &Path,
        instance_id: &ContractInstanceId,
        payload: usize,
        age: Duration,
    ) -> u64 {
        WEBAPP_CACHE_ACCESS.remove(instance_id);
        CONTRACT_CACHE_REFRESH.remove(instance_id);
        let encoded = instance_id.encode();
        let dir = root.join(&encoded);
        std::fs::create_dir_all(&dir).expect("create entry dir");
        std::fs::write(dir.join("index.html"), vec![b'x'; payload]).expect("write payload");
        let hash_path = root.join(format!("{encoded}.hash"));
        std::fs::write(&hash_path, 0u64.to_be_bytes()).expect("write sentinel");
        set_marker_age(&hash_path, age);
        payload as u64 + SENTINEL_BYTES
    }

    fn set_marker_age(path: &Path, age: Duration) {
        let when = SystemTime::now() - age;
        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(when))
            .expect("set marker mtime");
    }

    fn dir_present(root: &Path, instance_id: &ContractInstanceId) -> bool {
        root.join(instance_id.encode()).exists()
    }

    /// A contract plus a state carrying a REAL packed web archive, so
    /// `unpack_if_stale` performs a genuine extraction instead of taking its
    /// matching-hash early return. `seed` distinguishes contract keys.
    fn webapp_contract_and_state(seed: &[u8]) -> (ContractContainer, WrappedState) {
        let mut archive = tar::Builder::new(std::io::Cursor::new(Vec::new()));
        let body: &[u8] = b"<html><body>hello</body></html>";
        let mut header = tar::Header::new_gnu();
        header.set_size(body.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        archive
            .append_data(&mut header, "index.html", body)
            .expect("append to archive");
        let packed = WebApp::from_data(Vec::new(), archive)
            .expect("build web app")
            .pack()
            .expect("pack web app");
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(seed.to_vec())),
            Parameters::from(vec![0]),
        )));
        (contract, WrappedState::new(packed))
    }

    fn sentinel_present(root: &Path, instance_id: &ContractInstanceId) -> bool {
        root.join(format!("{}.hash", instance_id.encode())).exists()
    }

    /// `with_root` materializes the directory it is going to sweep, including
    /// missing parents.
    ///
    /// The point is the startup log next to it: nothing else in the node names
    /// the directory this code DELETES from, so the one moment the cache takes
    /// ownership of a path is the moment to say which path it is. Creating it
    /// here is what makes that log a statement of fact rather than of intent,
    /// and it is what turns "the root is a file" or "the root is not writable"
    /// into a startup warning instead of a cache that silently never populates.
    #[test]
    fn with_root_creates_the_cache_directory_it_will_sweep() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().join("nested").join("webapp_cache");
        assert!(
            !root.exists(),
            "premise: the root must be missing, or creating it proves nothing"
        );

        let cache = WebappCache::with_root(root.clone());

        assert!(
            root.is_dir(),
            "with_root must create the directory (and its parents) it will \
             unpack into and sweep"
        );
        assert_eq!(
            cache.root(),
            root.as_path(),
            "and must still be rooted exactly where it was told"
        );
    }

    /// A root that already exists as a FILE must not panic the server at
    /// startup.
    ///
    /// This is one of the two shapes the eager `create_dir_all` exists to
    /// surface (the other is an unwritable path). Both are operator
    /// misconfigurations, and both leave the webapp cache non-functional, but
    /// neither is fatal to the node: everything except web-contract serving is
    /// unaffected, so the correct response is a warning naming the path, not a
    /// refusal to start. A future `.expect()` here would take a node down over
    /// a stray file.
    #[test]
    fn with_root_tolerates_a_root_that_is_not_a_directory() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().join("webapp_cache");
        std::fs::write(&root, b"not a directory").expect("seed a file at the root path");

        let cache = WebappCache::with_root(root.clone());

        assert_eq!(
            cache.root(),
            root.as_path(),
            "construction must succeed and keep the configured root"
        );
        assert!(
            root.is_file(),
            "and must not have replaced the operator's file with a directory"
        );
    }

    /// Boundary: a cache whose total is exactly the budget is left untouched.
    #[tokio::test]
    async fn webapp_cache_sweep_is_noop_at_or_under_budget() {
        let root = tempfile::tempdir().expect("tempdir");
        let (old, new) = (cache_id(1, 0), cache_id(1, 1));
        let old_size = seed_cache_entry(root.path(), &old, 4096, Duration::from_secs(86_400));
        let new_size = seed_cache_entry(root.path(), &new, 4096, Duration::from_secs(60));

        let sweep =
            enforce_webapp_cache_budget(&cache(root.path(), old_size + new_size), None).await;

        assert_eq!(sweep.total_before, old_size + new_size);
        assert!(
            sweep.evicted.is_empty(),
            "a cache exactly at budget must not evict: {sweep:?}"
        );
        assert!(dir_present(root.path(), &old) && dir_present(root.path(), &new));
    }

    /// The core property: victims are chosen oldest-USE-first, and the sweep
    /// stops as soon as the cache fits.
    #[tokio::test]
    async fn webapp_cache_sweep_evicts_least_recently_used_first() {
        let root = tempfile::tempdir().expect("tempdir");
        let ids: Vec<_> = (0..4).map(|slot| cache_id(2, slot)).collect();
        // Oldest first: 4 days, 3 days, 2 days, 1 hour.
        let ages = [
            Duration::from_secs(4 * 86_400),
            Duration::from_secs(3 * 86_400),
            Duration::from_secs(2 * 86_400),
            Duration::from_secs(3_600),
        ];
        let mut size = 0;
        for (id, age) in ids.iter().zip(ages) {
            size = seed_cache_entry(root.path(), id, 4096, age);
        }

        // Budget fits exactly two entries, so the two coldest must go.
        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size * 2), None).await;

        assert_eq!(sweep.evicted, vec![ids[0], ids[1]], "sweep: {sweep:?}");
        assert_eq!(sweep.bytes_freed, size * 2);
        assert!(!dir_present(root.path(), &ids[0]));
        assert!(!dir_present(root.path(), &ids[1]));
        assert!(dir_present(root.path(), &ids[2]));
        assert!(dir_present(root.path(), &ids[3]));
    }

    /// Eviction must be least-recently-USED, not least-recently-created:
    /// refreshing the on-disk marker for the oldest-created entry has to move it
    /// to the front of the keep set. This is the end-to-end proof that
    /// `persist_cache_access_marker` feeds the ranking `scan_webapp_cache` reads.
    #[tokio::test]
    async fn webapp_cache_access_marker_makes_an_old_entry_most_recently_used() {
        let root = tempfile::tempdir().expect("tempdir");
        let (oldest, middle, newest) = (cache_id(3, 0), cache_id(3, 1), cache_id(3, 2));
        let size = seed_cache_entry(root.path(), &oldest, 4096, Duration::from_secs(30 * 86_400));
        seed_cache_entry(root.path(), &middle, 4096, Duration::from_secs(86_400));
        seed_cache_entry(root.path(), &newest, 4096, Duration::from_secs(3_600));

        // The oldest-created entry is the one being used right now.
        persist_cache_access_marker(root.path().join(format!("{}.hash", oldest.encode()))).await;

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size), None).await;

        assert_eq!(
            sweep.evicted,
            vec![middle, newest],
            "the touched entry must survive as most-recently-used: {sweep:?}"
        );
        assert!(dir_present(root.path(), &oldest));
    }

    /// The entry whose request triggered the sweep is never its own victim,
    /// even when it is the coldest thing on disk.
    #[tokio::test]
    async fn webapp_cache_sweep_never_evicts_the_entry_in_use() {
        let root = tempfile::tempdir().expect("tempdir");
        let (in_use, other) = (cache_id(4, 0), cache_id(4, 1));
        let size = seed_cache_entry(root.path(), &in_use, 4096, Duration::from_secs(30 * 86_400));
        seed_cache_entry(root.path(), &other, 4096, Duration::from_secs(3_600));

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size), Some(in_use)).await;

        assert_eq!(sweep.evicted, vec![other], "sweep: {sweep:?}");
        assert!(dir_present(root.path(), &in_use));
        assert!(!dir_present(root.path(), &other));
    }

    /// An entry a request touched moments ago is protected even though the
    /// request holds no lock — this is the in-flight guard for the serve paths,
    /// which read the unpacked files without taking `CONTRACT_CACHE_LOCKS`.
    #[tokio::test]
    async fn webapp_cache_sweep_skips_recently_accessed_entry() {
        let root = tempfile::tempdir().expect("tempdir");
        let (serving, other) = (cache_id(5, 0), cache_id(5, 1));
        let size = seed_cache_entry(
            root.path(),
            &serving,
            4096,
            Duration::from_secs(30 * 86_400),
        );
        seed_cache_entry(root.path(), &other, 4096, Duration::from_secs(3_600));

        record_cache_access(serving);
        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size), None).await;

        assert_eq!(sweep.evicted, vec![other], "sweep: {sweep:?}");
        assert!(dir_present(root.path(), &serving));
    }

    /// The in-flight exemption is time-bounded (AGENTS.md: GC exemptions must
    /// expire). Once `WEBAPP_CACHE_EVICTION_MIN_IDLE` has passed, the same entry
    /// is evictable again — otherwise a single visit would pin a webapp forever.
    #[tokio::test(start_paused = true)]
    async fn webapp_cache_sweep_access_exemption_expires() {
        let root = tempfile::tempdir().expect("tempdir");
        let (served, other) = (cache_id(6, 0), cache_id(6, 1));
        let size = seed_cache_entry(root.path(), &served, 4096, Duration::from_secs(30 * 86_400));
        seed_cache_entry(root.path(), &other, 4096, Duration::from_secs(3_600));

        // Pin the window itself, not just that *some* window elapses: the
        // advance below is expressed in terms of the constant, so without this
        // the test would keep passing if the exemption were widened to
        // effectively-permanent. It must outlast the 30s network fetch in
        // `ensure_contract_cached` and stay far short of a browsing session.
        assert!(
            WEBAPP_CACHE_EVICTION_MIN_IDLE > Duration::from_secs(30)
                && WEBAPP_CACHE_EVICTION_MIN_IDLE < Duration::from_secs(3_600),
            "in-flight exemption is not a sane finite window: {WEBAPP_CACHE_EVICTION_MIN_IDLE:?}"
        );

        record_cache_access(served);
        let protected = enforce_webapp_cache_budget(&cache(root.path(), size), None).await;
        assert_eq!(protected.evicted, vec![other], "sweep: {protected:?}");

        tokio::time::advance(WEBAPP_CACHE_EVICTION_MIN_IDLE + Duration::from_secs(1)).await;
        let expired = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(expired.evicted, vec![served], "sweep: {expired:?}");
        assert!(!dir_present(root.path(), &served));
    }

    /// An eviction must never race a re-extraction: while `unpack_if_stale`
    /// holds a contract's cache lock, the sweep leaves that entry alone and
    /// takes the next-coldest victim instead.
    #[tokio::test]
    async fn webapp_cache_sweep_skips_entry_with_unpack_in_flight() {
        let root = tempfile::tempdir().expect("tempdir");
        let (unpacking, other) = (cache_id(7, 0), cache_id(7, 1));
        let size = seed_cache_entry(
            root.path(),
            &unpacking,
            4096,
            Duration::from_secs(30 * 86_400),
        );
        seed_cache_entry(root.path(), &other, 4096, Duration::from_secs(3_600));

        let guard = acquire_cache_lock(&unpacking).await;
        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size), None).await;
        drop(guard);

        assert_eq!(sweep.evicted, vec![other], "sweep: {sweep:?}");
        assert!(dir_present(root.path(), &unpacking));
    }

    /// Evicting must remove the `{key}.hash` sentinel as well as the tree. A
    /// leftover sentinel reads as a WARM cache over an empty directory, which
    /// would 404 every request until the contract's state happened to change.
    #[tokio::test]
    async fn webapp_cache_sweep_removes_sentinel_with_directory() {
        let root = tempfile::tempdir().expect("tempdir");
        let evicted = cache_id(8, 0);
        seed_cache_entry(
            root.path(),
            &evicted,
            4096,
            Duration::from_secs(30 * 86_400),
        );

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(sweep.evicted, vec![evicted], "sweep: {sweep:?}");
        assert!(!dir_present(root.path(), &evicted));
        assert!(
            !sentinel_present(root.path(), &evicted),
            "sentinel left behind would make the empty cache read as warm"
        );
    }

    /// `refresh_cache_if_due` short-circuits on a fresh `CONTRACT_CACHE_REFRESH`
    /// timer alone, so an evicted contract that kept its timer would serve 404s
    /// from the emptied directory for the rest of the TTL window.
    #[tokio::test]
    async fn webapp_cache_sweep_clears_refresh_timer_for_evicted_entry() {
        let root = tempfile::tempdir().expect("tempdir");
        let evicted = cache_id(9, 0);
        seed_cache_entry(
            root.path(),
            &evicted,
            4096,
            Duration::from_secs(30 * 86_400),
        );
        CONTRACT_CACHE_REFRESH.insert(evicted, Instant::now());

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(sweep.evicted, vec![evicted], "sweep: {sweep:?}");
        assert!(
            !CONTRACT_CACHE_REFRESH.contains_key(&evicted),
            "an evicted contract must not keep a fresh reconcile timer"
        );
        assert!(
            !WEBAPP_CACHE_ACCESS.contains_key(&evicted),
            "an evicted contract must not keep an access record"
        );
    }

    /// Resilience: one entry that cannot be removed must not abort the sweep.
    /// The failure is injected by replacing a sentinel with a directory, so
    /// `remove_file` fails deterministically (EISDIR) for any user on any
    /// platform — no permission tricks that root would bypass.
    ///
    /// The unremovable entry is also left INTACT rather than half-deleted: the
    /// alternative (drop the tree, keep the sentinel) is the warm-but-empty
    /// shape that 404s.
    #[tokio::test]
    async fn webapp_cache_sweep_continues_after_entry_removal_failure() {
        let root = tempfile::tempdir().expect("tempdir");
        let (broken, other) = (cache_id(10, 0), cache_id(10, 1));
        let size = seed_cache_entry(root.path(), &broken, 4096, Duration::from_secs(30 * 86_400));
        seed_cache_entry(root.path(), &other, 4096, Duration::from_secs(3_600));

        // Replace the sentinel with a directory of the same name.
        let sentinel = root.path().join(format!("{}.hash", broken.encode()));
        std::fs::remove_file(&sentinel).expect("remove sentinel");
        std::fs::create_dir(&sentinel).expect("sentinel as dir");
        // Sentinel gone as a file, so the entry ranks on its directory mtime.
        set_marker_age(
            &root.path().join(broken.encode()),
            Duration::from_secs(30 * 86_400),
        );

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), size), None).await;

        assert_eq!(
            sweep.evicted,
            vec![other],
            "a failed removal must not abort the sweep: {sweep:?}"
        );
        assert!(
            dir_present(root.path(), &broken),
            "an entry whose sentinel cannot be removed must be left intact"
        );
        assert!(!dir_present(root.path(), &other));
    }

    /// The sweep owns only `<base58>` / `<base58>.hash` pairs: anything else in
    /// the cache root is neither counted nor deleted.
    #[tokio::test]
    async fn webapp_cache_sweep_ignores_unrecognized_paths() {
        let root = tempfile::tempdir().expect("tempdir");
        let known = cache_id(11, 0);
        let size = seed_cache_entry(root.path(), &known, 4096, Duration::from_secs(30 * 86_400));
        std::fs::write(root.path().join("README.txt"), vec![b'z'; 8192]).expect("write stray file");
        let stray_dir = root.path().join("not a contract key");
        std::fs::create_dir(&stray_dir).expect("stray dir");
        std::fs::write(stray_dir.join("payload.bin"), vec![b'z'; 8192]).expect("stray payload");

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(
            sweep.total_before, size,
            "unrecognized paths must not be accounted: {sweep:?}"
        );
        assert_eq!(sweep.evicted, vec![known], "sweep: {sweep:?}");
        assert!(root.path().join("README.txt").exists());
        assert!(stray_dir.join("payload.bin").exists());
    }

    /// Scale edge case: when every entry is protected the sweep leaves the cache
    /// over budget rather than deleting something in use, and returns normally
    /// (the next sweep retries).
    #[tokio::test]
    async fn webapp_cache_sweep_stays_over_budget_when_all_entries_protected() {
        let root = tempfile::tempdir().expect("tempdir");
        let (first, second) = (cache_id(12, 0), cache_id(12, 1));
        seed_cache_entry(root.path(), &first, 4096, Duration::from_secs(30 * 86_400));
        seed_cache_entry(root.path(), &second, 4096, Duration::from_secs(30 * 86_400));
        record_cache_access(first);
        record_cache_access(second);

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert!(sweep.evicted.is_empty(), "sweep: {sweep:?}");
        assert_eq!(sweep.bytes_freed, 0);
        assert!(dir_present(root.path(), &first) && dir_present(root.path(), &second));
    }

    /// A missing cache root must read as "no entries", not blow up. Asserted on
    /// the scan directly: a panic inside the sweep's `spawn_blocking` would be
    /// caught by the `JoinError` arm and reported as an empty sweep, so the
    /// sweep-level assertion below cannot tell graceful handling from a
    /// swallowed panic.
    #[test]
    fn webapp_cache_scan_of_missing_root_is_empty_not_a_panic() {
        let root = tempfile::tempdir().expect("tempdir");
        assert!(scan_webapp_cache(&root.path().join("does-not-exist")).is_empty());
    }

    /// An empty cache root must not panic or report anything to evict.
    #[tokio::test]
    async fn webapp_cache_sweep_handles_empty_and_missing_root() {
        let root = tempfile::tempdir().expect("tempdir");
        let empty = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;
        assert_eq!(empty.total_before, 0);
        assert!(empty.evicted.is_empty());

        let missing =
            enforce_webapp_cache_budget(&cache(&root.path().join("does-not-exist"), 0), None).await;
        assert_eq!(missing.total_before, 0);
        assert!(missing.evicted.is_empty());
    }

    /// The on-disk marker refresh is throttled: the first access of a window
    /// persists, subsequent ones don't, and a new window persists again. Without
    /// the throttle every subresource of every page load would pay an
    /// `utimensat`.
    #[tokio::test(start_paused = true)]
    async fn webapp_cache_access_marker_refresh_is_throttled() {
        let id = cache_id(13, 0);
        WEBAPP_CACHE_ACCESS.remove(&id);

        assert!(
            record_cache_access(id),
            "first access of an entry must persist the marker"
        );
        assert!(
            !record_cache_access(id),
            "a second access in the same window must not re-touch the marker"
        );

        tokio::time::advance(WEBAPP_CACHE_ACCESS_TOUCH_INTERVAL - Duration::from_secs(1)).await;
        assert!(!record_cache_access(id), "still inside the throttle window");

        tokio::time::advance(Duration::from_secs(2)).await;
        assert!(
            record_cache_access(id),
            "a new window must persist the marker again"
        );
    }

    /// The marker refresh must actually move the sentinel's mtime forward (this
    /// is what makes the ranking survive a restart), and must not disturb its
    /// contents — those are the state hash `unpack_if_stale` compares against.
    #[tokio::test]
    async fn webapp_cache_access_marker_updates_mtime_without_touching_contents() {
        let root = tempfile::tempdir().expect("tempdir");
        let id = cache_id(14, 0);
        seed_cache_entry(root.path(), &id, 1024, Duration::from_secs(30 * 86_400));
        let sentinel = root.path().join(format!("{}.hash", id.encode()));
        let before = std::fs::metadata(&sentinel)
            .and_then(|meta| meta.modified())
            .expect("sentinel mtime");

        persist_cache_access_marker(sentinel.clone()).await;

        let after = std::fs::metadata(&sentinel)
            .and_then(|meta| meta.modified())
            .expect("sentinel mtime");
        assert!(after > before, "marker refresh must move the mtime forward");
        assert_eq!(
            std::fs::read(&sentinel).expect("sentinel contents"),
            0u64.to_be_bytes(),
            "the state hash must survive a marker refresh"
        );
    }

    /// A marker refresh against a cold cache (no sentinel yet) is a no-op, not
    /// an error that could fail a user's request.
    #[tokio::test]
    async fn webapp_cache_access_marker_tolerates_missing_sentinel() {
        let root = tempfile::tempdir().expect("tempdir");
        persist_cache_access_marker(root.path().join("absent.hash")).await;
    }

    /// `from_base58` is not a strict filter — stdlib zero-pads a short decode
    /// instead of rejecting it, so ordinary directory names made of base58
    /// characters (`tmp`, `data`, `assets`) parse into well-formed but WRONG
    /// ids. Without the round-trip check the sweep would charge those bytes to
    /// a phantom entry, "evict" a path that does not exist, and count bytes it
    /// never freed — reporting success while staying over budget.
    #[tokio::test]
    async fn webapp_cache_sweep_ignores_names_that_zero_pad_into_valid_ids() {
        // Guard the premise: if stdlib ever made `from_base58` strict, this
        // test would silently stop covering anything.
        let padded =
            ContractInstanceId::from_base58("tmp").expect("stdlib zero-pads short decodes");
        assert_ne!(
            padded.encode(),
            "tmp",
            "premise: a short base58 name must decode to a DIFFERENT id"
        );

        let root = tempfile::tempdir().expect("tempdir");
        let known = cache_id(15, 0);
        let size = seed_cache_entry(root.path(), &known, 4096, Duration::from_secs(30 * 86_400));
        for stray in ["tmp", "data", "assets"] {
            let dir = root.path().join(stray);
            std::fs::create_dir(&dir).expect("stray dir");
            std::fs::write(dir.join("payload.bin"), vec![b'z'; 8192]).expect("stray payload");
        }

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(
            sweep.total_before, size,
            "base58-parseable non-entries must not be accounted: {sweep:?}"
        );
        assert_eq!(sweep.evicted, vec![known], "sweep: {sweep:?}");
        assert_eq!(
            sweep.bytes_freed, size,
            "bytes_freed must only count entries actually deleted: {sweep:?}"
        );
        for stray in ["tmp", "data", "assets"] {
            assert!(root.path().join(stray).join("payload.bin").exists());
        }
    }

    /// Concurrent sweeps must not each evict a full deficit's worth. Each takes
    /// its own `live` snapshot, so without the in-progress gate N simultaneous
    /// unpacks drive the cache well below budget and over-report `bytes_freed`.
    #[tokio::test]
    async fn webapp_cache_concurrent_sweeps_do_not_over_evict() {
        let root = tempfile::tempdir().expect("tempdir");
        let ids: Vec<_> = (0..6).map(|slot| cache_id(16, slot)).collect();
        let mut size = 0;
        for (offset, id) in ids.iter().enumerate() {
            size = seed_cache_entry(
                root.path(),
                id,
                4096,
                Duration::from_secs((30 - offset as u64) * 86_400),
            );
        }
        // Budget for 4 of the 6 entries, so a single correct sweep evicts 2.
        let shared = cache(root.path(), size * 4);
        let in_use = ids[5];

        let mut sweeps = Vec::new();
        for _ in 0..4 {
            let shared = shared.clone();
            sweeps.push(tokio::spawn(async move {
                maybe_enforce_webapp_cache_budget(&shared, in_use, SweepTrigger::Unpack).await;
            }));
        }
        for sweep in sweeps {
            sweep.await.expect("sweep task must not panic");
        }

        let survivors = ids.iter().filter(|id| dir_present(root.path(), id)).count();
        assert_eq!(
            survivors, 4,
            "concurrent sweeps must together evict the deficit exactly once"
        );
    }

    /// The debounce decision, isolated from the filesystem. An unpack grew the
    /// cache so it always sweeps; a reconcile rewrote nothing so it waits out
    /// `WEBAPP_CACHE_SWEEP_INTERVAL`, otherwise every contract's 30-second
    /// refresh would pay for a directory walk.
    #[tokio::test(start_paused = true)]
    async fn webapp_cache_sweep_is_due_debounces_only_reconciles() {
        let now = Instant::now();
        assert!(
            sweep_is_due(SweepTrigger::Reconcile, None, now),
            "a never-swept cache is due"
        );
        assert!(
            !sweep_is_due(SweepTrigger::Reconcile, Some(now), now),
            "a reconcile right after a sweep must be debounced"
        );
        assert!(
            sweep_is_due(SweepTrigger::Unpack, Some(now), now),
            "an unpack grew the cache, so it always sweeps"
        );
        assert!(
            !sweep_is_due(
                SweepTrigger::Reconcile,
                Some(now),
                now + WEBAPP_CACHE_SWEEP_INTERVAL - Duration::from_secs(1)
            ),
            "still inside the debounce window"
        );
        assert!(
            sweep_is_due(
                SweepTrigger::Reconcile,
                Some(now),
                now + WEBAPP_CACHE_SWEEP_INTERVAL
            ),
            "the debounce window must expire"
        );
    }

    // -------------------------------------------------------------------------
    // Wiring: the size bound has to actually RUN, and the in-flight guard has to
    // actually ARM, on the real handler paths. Everything above tests the sweep
    // in isolation, so without these the whole feature could be deleted from
    // `unpack_if_stale` / `refresh_cache_if_due` with a green suite.
    // -------------------------------------------------------------------------

    /// Drives the real reconcile path — `refresh_cache_if_due` →
    /// `ensure_contract_cached` → `handle_get_response` → `unpack_if_stale`
    /// (matching-hash early return) — and asserts the budget sweep ran.
    ///
    /// Pins the `SweepTrigger::Reconcile` call site: delete it and the
    /// over-budget decoys below survive.
    #[tokio::test]
    async fn reconcile_path_enforces_the_webapp_cache_budget() {
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(vec![7, 7, 7, 7])),
            Parameters::from(vec![1]),
        )));
        let instance_id = *contract.key().id();
        let state = WrappedState::new(vec![4, 4, 4]);

        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), SENTINEL_BYTES);
        clear_cache(&instance_id).await;
        WEBAPP_CACHE_ACCESS.remove(&instance_id);

        // Warm + matching hash ⇒ `unpack_if_stale` takes its early return, so
        // this exercises the RECONCILE trigger rather than the unpack one.
        std::fs::create_dir_all(webapp_cache.entry_dir(&instance_id)).expect("entry dir");
        std::fs::write(
            webapp_cache.hash_path(&instance_id),
            hash_state(state.as_ref()).to_be_bytes(),
        )
        .expect("sentinel");

        // Decoys the sweep must evict to get under the (tiny) budget.
        let decoys: Vec<_> = (0..2).map(|slot| cache_id(17, slot)).collect();
        for decoy in &decoys {
            seed_cache_entry(root.path(), decoy, 4096, Duration::from_secs(30 * 86_400));
        }

        let (sender, mut rx) = request_channel();
        let handler = {
            let webapp_cache = webapp_cache.clone();
            tokio::spawn(async move {
                refresh_cache_if_due(instance_id, &sender, &webapp_cache)
                    .await
                    .map(|_| ())
            })
        };
        serve_one_get(&mut rx, &contract, &state).await;
        handler
            .await
            .expect("handler must not panic")
            .expect("reconcile must succeed");

        for decoy in &decoys {
            assert!(
                !dir_present(root.path(), decoy),
                "the reconcile path must enforce the size bound"
            );
        }
        assert!(
            dir_present(root.path(), &instance_id),
            "the contract being reconciled must never be its own sweep's victim"
        );
    }

    /// Same wiring, one layer down and on the UNPACK trigger: `unpack_if_stale`
    /// re-extracts a real web archive and must then sweep. Pins the
    /// `SweepTrigger::Unpack` call site.
    #[tokio::test]
    async fn unpack_enforces_the_webapp_cache_budget() {
        let (contract, state) = webapp_contract_and_state(&[0xa1]);
        let instance_id = *contract.key().id();

        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), SENTINEL_BYTES);
        clear_cache(&instance_id).await;
        WEBAPP_CACHE_ACCESS.remove(&instance_id);

        let decoys: Vec<_> = (0..2).map(|slot| cache_id(18, slot)).collect();
        for decoy in &decoys {
            seed_cache_entry(root.path(), decoy, 4096, Duration::from_secs(30 * 86_400));
        }

        // No sentinel ⇒ a genuine unpack, which is the only event that grows
        // the cache and therefore always sweeps.
        unpack_if_stale(&contract, state.as_ref(), &webapp_cache)
            .await
            .expect("unpack must succeed");

        assert!(
            webapp_cache.hash_path(&instance_id).exists(),
            "premise: the unpack must have actually happened"
        );
        for decoy in &decoys {
            assert!(
                !dir_present(root.path(), decoy),
                "an unpack must enforce the size bound"
            );
        }
    }

    /// The in-flight guard has to arm on the serve path: `refresh_cache_if_due`
    /// must record the access for a warm entry, otherwise a concurrent sweep has
    /// nothing telling it the entry is being read right now. Pins the
    /// `note_cache_access` call site in `refresh_cache_if_due`.
    #[tokio::test]
    async fn serving_a_warm_entry_marks_it_in_use() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0xc1;
        bytes[1] = 0x01;
        let instance_id = ContractInstanceId::new(bytes);

        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), u64::MAX);
        clear_cache(&instance_id).await;
        WEBAPP_CACHE_ACCESS.remove(&instance_id);

        std::fs::create_dir_all(webapp_cache.entry_dir(&instance_id)).expect("entry dir");
        std::fs::write(webapp_cache.hash_path(&instance_id), 0u64.to_be_bytes()).expect("sentinel");
        // Fresh reconcile timer ⇒ the warm fast path returns before any fetch,
        // so the access record is the only thing this can be observing.
        CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());

        let (sender, _rx) = request_channel();
        refresh_cache_if_due(instance_id, &sender, &webapp_cache)
            .await
            .expect("warm fast path must succeed");

        assert!(
            accessed_recently(&instance_id),
            "serving a warm entry must mark it in use for the eviction guard"
        );
    }

    /// Same, for the shell root: `contract_home` fetches and then serves, so it
    /// must mark the entry in use too. Pins the `note_cache_access` call site in
    /// `contract_home_in`.
    #[tokio::test]
    async fn contract_home_marks_the_entry_in_use() {
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(vec![3, 1, 4, 1])),
            Parameters::from(vec![5, 9]),
        )));
        let instance_id = *contract.key().id();
        let state = WrappedState::new(vec![2, 6, 5]);

        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), u64::MAX);
        clear_cache(&instance_id).await;
        WEBAPP_CACHE_ACCESS.remove(&instance_id);

        // Matching hash ⇒ no unpack needed; we only care about the marking.
        std::fs::create_dir_all(webapp_cache.entry_dir(&instance_id)).expect("entry dir");
        std::fs::write(
            webapp_cache.hash_path(&instance_id),
            hash_state(state.as_ref()).to_be_bytes(),
        )
        .expect("sentinel");

        let (sender, mut rx) = request_channel();
        let key = instance_id.to_string();
        let handler = {
            let webapp_cache = webapp_cache.clone();
            tokio::spawn(async move {
                contract_home(
                    key,
                    sender,
                    AuthToken::generate(),
                    ApiVersion::V1,
                    None,
                    None,
                    false,
                    &webapp_cache,
                )
                .await
                .map(|_| ())
            })
        };
        serve_one_get(&mut rx, &contract, &state).await;
        handler
            .await
            .expect("handler must not panic")
            .expect("contract_home must succeed");

        assert!(
            accessed_recently(&instance_id),
            "contract_home must mark the entry in use for the eviction guard"
        );
    }

    /// Cross-process regression. The cache directory is per-USER but the guards
    /// are per-process, and the documented multi-peer setup runs several nodes
    /// as one user. When another process evicts an entry, this process's
    /// reconcile timer is still fresh and knows nothing about it — so returning
    /// on the timer alone served 404s out of the emptied directory for the rest
    /// of the TTL window. The re-stat under the refresh lock must notice the
    /// entry is gone and refetch.
    #[tokio::test]
    async fn eviction_by_another_process_forces_a_refetch_despite_a_fresh_timer() {
        // A real archive: the entry is genuinely cold here, so the refetch this
        // test is asserting on runs a real unpack rather than the matching-hash
        // early return.
        let (contract, state) = webapp_contract_and_state(&[0xb2]);
        let instance_id = *contract.key().id();

        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), u64::MAX);
        clear_cache(&instance_id).await;
        WEBAPP_CACHE_ACCESS.remove(&instance_id);

        // The state another process left behind: entry gone from disk, but OUR
        // reconcile timer still fresh (its `CONTRACT_CACHE_REFRESH.remove` only
        // reached its own process).
        CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
        assert!(
            !webapp_cache.hash_path(&instance_id).exists(),
            "premise: the entry must be absent"
        );

        let (sender, mut rx) = request_channel();
        let handler = {
            let webapp_cache = webapp_cache.clone();
            tokio::spawn(async move {
                refresh_cache_if_due(instance_id, &sender, &webapp_cache)
                    .await
                    .map(|_| ())
            })
        };

        // A refetch means the #3945 cold-path gate runs first; answer it as
        // "the node stores this contract", then serve the GET.
        answer_presence_query_hosted(&mut rx, instance_id).await;
        serve_one_get(&mut rx, &contract, &state).await;
        handler
            .await
            .expect("handler must not panic")
            .expect("refresh must succeed");

        assert!(
            webapp_cache.hash_path(&instance_id).exists(),
            "a fresh timer must not suppress the refetch of an entry another \
             process evicted — otherwise the request 404s for the rest of the TTL"
        );
        // Pins the COLD-fetch `note_cache_access`, which no other test reaches:
        // the entry was cold, so the warm-path call is skipped and this is the
        // only writer. Without it a freshly-fetched entry is unprotected between
        // the fetch and the caller's read of the files.
        assert!(
            accessed_recently(&instance_id),
            "a contract fetched to populate a cold entry must be marked in use \
             before the caller reads it"
        );
    }

    /// The reconcile debounce has to be wired into the sweep gate, not merely
    /// exist: `sweep_is_due` is unit-tested in isolation, so dropping the call
    /// to it would leave every 30-second refresh of every contract paying for a
    /// full recursive directory walk.
    #[tokio::test]
    async fn reconcile_sweeps_are_debounced_in_practice() {
        let root = tempfile::tempdir().expect("tempdir");
        let webapp_cache = cache(root.path(), 0);
        let in_use = cache_id(19, 0);

        let first = cache_id(19, 1);
        seed_cache_entry(root.path(), &first, 4096, Duration::from_secs(30 * 86_400));
        maybe_enforce_webapp_cache_budget(&webapp_cache, in_use, SweepTrigger::Reconcile).await;
        assert!(
            !dir_present(root.path(), &first),
            "premise: the first reconcile must sweep, or the debounce below \
             proves nothing"
        );

        // Re-seed and immediately reconcile again. The budget is still 0, so a
        // sweep that ran would evict — the debounce is the only thing that can
        // keep this entry alive.
        let second = cache_id(19, 2);
        seed_cache_entry(root.path(), &second, 4096, Duration::from_secs(30 * 86_400));
        maybe_enforce_webapp_cache_budget(&webapp_cache, in_use, SweepTrigger::Reconcile).await;

        assert!(
            dir_present(root.path(), &second),
            "a second reconcile inside WEBAPP_CACHE_SWEEP_INTERVAL must skip the \
             sweep entirely"
        );
    }

    /// One sweep deletes at most `WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP`, so the
    /// first sweep on a node upgrading with an unbounded legacy cache cannot
    /// stall a request behind an unbounded number of `remove_dir_all`s. The
    /// remainder is left for the next sweep rather than dropped.
    #[tokio::test]
    async fn webapp_cache_sweep_caps_evictions_per_pass() {
        let root = tempfile::tempdir().expect("tempdir");
        let over_cap = WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP + 3;
        let ids: Vec<_> = (0..over_cap).map(|slot| cache_id(20, slot as u8)).collect();
        for (offset, id) in ids.iter().enumerate() {
            seed_cache_entry(
                root.path(),
                id,
                4096,
                Duration::from_secs((over_cap - offset) as u64 * 86_400),
            );
        }

        let sweep = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;

        assert_eq!(
            sweep.evicted.len(),
            WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP,
            "one sweep must not delete more than the cap: {sweep:?}"
        );
        // The cap must take the COLDEST entries, not an arbitrary prefix.
        assert_eq!(
            sweep.evicted,
            ids[..WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP],
            "the capped sweep must still evict least-recently-used first"
        );
        let survivors = ids.iter().filter(|id| dir_present(root.path(), id)).count();
        assert_eq!(survivors, over_cap - WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP);

        // Still over budget, so the next sweep picks up where this one stopped.
        let next = enforce_webapp_cache_budget(&cache(root.path(), 0), None).await;
        assert_eq!(
            next.evicted.len(),
            over_cap - WEBAPP_CACHE_MAX_EVICTIONS_PER_SWEEP,
            "the remainder must be evicted by the following sweep: {next:?}"
        );
    }

    /// Regression test for #3940, updated for the #3945 store-presence gate.
    /// `variable_content` must trigger a network fetch when the contract's
    /// webapp cache is cold **and** the contract is locally present. This
    /// models the REAL #3940 cross-contract scenario: a Delta page `<img>`s a
    /// SEPARATE contract X that the node has fetched-and-STORED before (for
    /// some user) but that THIS user never visited at its root — so X is NOT in
    /// the application-subscription set, only in the contract store. The gate
    /// must still resolve it (store presence is the bar #3945 names), proving
    /// the fix does not re-break #3940 for stored-but-unsubscribed contracts.
    ///
    /// Prior to #3942 a cold-cache subpath request returned 404; #3942 made it
    /// fetch; #3945 narrows that fetch to locally-present instances — answered
    /// here via the `NodeDiagnostics` presence query as "node hosts/stores X".
    ///
    /// Verifies the handler emits the `NewConnection` + `Request(Get)` fetch
    /// pair on the client-connection channel for the present instance. The
    /// fetch is cancelled mid-flight (we don't deliver a response) so the test
    /// stays bounded. See `variable_content_skips_fetch_for_unknown_instance`
    /// for the security side of the gate.
    #[tokio::test]
    async fn variable_content_triggers_fetch_on_cache_miss() {
        // Unique 32-byte seed so the resulting contract key does not collide
        // with other tests, and any cache residue from prior runs is scrubbed
        // via `clear_cache`.
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x40;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                variable_content(
                    key.clone(),
                    format!("/v1/contract/web/{key}/image.jpg"),
                    ApiVersion::V1,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|_| ())
            })
        };

        // Cold cache → the #3945 gate runs. `expect_fetch_pair_cold` answers
        // the presence query as "node hosts/stores X" (stored-but-unsubscribed,
        // the #3940 cross-contract case), then asserts the resulting
        // `NewConnection` + `Get` fetch pair for our contract key.
        expect_fetch_pair_cold(&mut rx, instance_id).await;

        handler.abort();
        // Clean up after the test — handler was aborted mid-fetch, so no
        // cache was written, but clear defensively to avoid accumulating
        // state in the shared XDG cache dir across runs.
        clear_cache(&instance_id).await;
    }

    /// Security regression for #3945. A cold-cache subresource request for an
    /// UNKNOWN contract (not in the store AND not subscribed) must NOT issue a
    /// network GET — that is the random-key DoS amplification vector #3942
    /// opened. The presence query returns empty `contract_states` and empty
    /// `subscriptions`, so the gate fails closed and the handler serves a 404
    /// from the empty cache directory (pre-#3942 behaviour), issuing no `Get`
    /// on the channel.
    ///
    /// Load-bearing: without the gate the handler would fall straight through
    /// to `ensure_contract_cached` and emit a `NewConnection` + `Get`, which
    /// this test's "no Get" assertion would catch.
    #[tokio::test]
    async fn variable_content_skips_fetch_for_unknown_instance() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x47;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                variable_content(
                    key.clone(),
                    format!("/v1/contract/web/{key}/image.jpg"),
                    ApiVersion::V1,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|r| r.into_response())
            })
        };

        // The #3945 presence query runs (cold cache). Answer it as "the node
        // has NO local presence for this contract" — empty contract_states AND
        // empty subscriptions → not locally known.
        answer_presence_query(&mut rx, instance_id, |_query_id| empty_diagnostics()).await;

        // The handler must finish and return a 404 — NO further Get may appear.
        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
            .await
            .expect("handler must finish without issuing a network fetch")
            .expect("handler must not panic")
            .expect("unknown-instance request must still resolve to a response");
        assert_eq!(
            result.status(),
            axum::http::StatusCode::NOT_FOUND,
            "an unknown cold-cache subresource must 404, not fetch"
        );

        // `answer_presence_query` already drained the query's Disconnect, so
        // the channel must now be empty — any residual NewConnection/Get here
        // would mean the gate wrongly let a fetch through.
        let mut saw_fetch = false;
        while let Ok(msg) = rx.try_recv() {
            match msg {
                ClientConnection::NewConnection { .. } => saw_fetch = true,
                ClientConnection::Request { req, .. } => {
                    if matches!(
                        req.as_ref(),
                        ClientRequest::ContractOp(ContractRequest::Get { .. })
                    ) {
                        saw_fetch = true;
                    }
                }
            }
        }
        assert!(
            !saw_fetch,
            "unknown-instance request must NOT issue a network fetch (#3945 DoS gate)"
        );

        clear_cache(&instance_id).await;
    }

    /// Fail-closed regression for #3945: when the presence query is NEVER
    /// answered (the node accepted the transient `NewConnection` but never
    /// replies to the `NodeDiagnostics` query), `is_locally_known` must time
    /// out and read as NOT known, so the cold-cache request 404s and issues NO
    /// network GET. This is the DoS guarantee under a wedged node — without the
    /// 5s recv timeout the request task would hang forever, which under a spray
    /// of unknown keys is itself a resource-exhaustion vector.
    ///
    /// Uses paused time so the 5s presence-query timeout elapses via
    /// `advance()` rather than wall-clock, keeping the test fast and
    /// deterministic.
    #[tokio::test(start_paused = true)]
    async fn variable_content_fails_closed_when_presence_query_unanswered() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x48;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                variable_content(
                    key.clone(),
                    format!("/v1/contract/web/{key}/image.jpg"),
                    ApiVersion::V1,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|r| r.into_response())
            })
        };

        // Answer the presence query's NewConnection with an id, then go SILENT
        // — never reply to the NodeDiagnostics query. Hold `callbacks` alive so
        // the channel doesn't close (a closed channel would short-circuit the
        // recv with `None`; we want to exercise the TIMEOUT branch specifically).
        let new_conn = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
            .await
            .expect("handler must send NewConnection for the presence query")
            .expect("channel must remain open");
        let _callbacks = match new_conn {
            ClientConnection::NewConnection { callbacks, .. } => {
                callbacks
                    .send(HostCallbackResult::NewId {
                        id: crate::client_events::ClientId::next(),
                    })
                    .expect("callback receiver live for query NewId");
                callbacks
            }
            other => panic!("presence query must open with NewConnection, got: {other:?}"),
        };

        // Drain the diagnostics query request itself (so the handler is now
        // blocked on its recv-with-timeout), then advance past the 5s bound.
        let _query = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
            .await
            .expect("handler must send the NodeDiagnostics query")
            .expect("channel must remain open");
        // Advance past PRESENCE_QUERY_TIMEOUT so the query recv times out → fail closed.
        tokio::time::advance(PRESENCE_QUERY_TIMEOUT + Duration::from_secs(1)).await;

        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
            .await
            .expect("handler must finish once the presence query times out")
            .expect("handler must not panic")
            .expect("request must still resolve to a response");
        assert_eq!(
            result.status(),
            axum::http::StatusCode::NOT_FOUND,
            "an unanswered presence query must fail closed → 404, not fetch"
        );

        // The handler drains its query Disconnect on the way out; nothing after
        // it may be a fetch.
        let mut saw_fetch = false;
        while let Ok(msg) = rx.try_recv() {
            match msg {
                ClientConnection::NewConnection { .. } => saw_fetch = true,
                ClientConnection::Request { req, .. } => {
                    if matches!(
                        req.as_ref(),
                        ClientRequest::ContractOp(ContractRequest::Get { .. })
                    ) {
                        saw_fetch = true;
                    }
                }
            }
        }
        assert!(
            !saw_fetch,
            "a timed-out presence query must NOT issue a network fetch (#3945 fail-closed)"
        );

        clear_cache(&instance_id).await;
    }

    /// Fail-closed regression for #3945: when the node accepts the transient
    /// `NewConnection` (so the SEND succeeds) but never replies with the
    /// `NewId` connection-id assignment, the FIRST `is_locally_known` recv
    /// timeout must fire and read as NOT known — so the cold-cache request 404s
    /// and issues NO network GET. This is the wedged-node case distinct from
    /// `variable_content_fails_closed_when_presence_query_unanswered` (which
    /// DELIVERS the `NewId` and then times out the SECOND, diagnostics-answer,
    /// recv) and from `variable_content_fails_closed_when_node_channel_closed`
    /// (where the `NewConnection` SEND itself fails). Here the gap is between a
    /// successful `NewConnection` send and a missing `NewId`: the first
    /// `tokio::time::timeout(PRESENCE_QUERY_TIMEOUT, recv())` whose `_ => return
    /// false` arm must hold the gate closed. If that arm returned true (fail
    /// open) the handler would proceed to fetch and this test would see a GET.
    ///
    /// Uses paused time so the 5s presence-query timeout elapses via
    /// `advance()` rather than wall-clock, keeping the test fast and
    /// deterministic.
    #[tokio::test(start_paused = true)]
    async fn variable_content_fails_closed_when_newid_never_arrives() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x4c;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                variable_content(
                    key.clone(),
                    format!("/v1/contract/web/{key}/image.jpg"),
                    ApiVersion::V1,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|r| r.into_response())
            })
        };

        // Accept the presence query's NewConnection so the SEND succeeds, but
        // NEVER reply with NewId. Hold `callbacks` alive so the channel stays
        // open (a closed channel would short-circuit the recv with `None` and
        // exercise a different path); we want the TIMEOUT branch of the FIRST
        // recv specifically.
        let new_conn = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
            .await
            .expect("handler must send NewConnection for the presence query")
            .expect("channel must remain open");
        let _callbacks = match new_conn {
            ClientConnection::NewConnection { callbacks, .. } => callbacks,
            other => panic!("presence query must open with NewConnection, got: {other:?}"),
        };

        // The handler is now blocked on its NewId recv-with-timeout. Advance
        // past PRESENCE_QUERY_TIMEOUT so that recv times out → fail closed.
        tokio::time::advance(PRESENCE_QUERY_TIMEOUT + Duration::from_secs(1)).await;

        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
            .await
            .expect("handler must finish once the NewId wait times out")
            .expect("handler must not panic")
            .expect("request must still resolve to a response");
        assert_eq!(
            result.status(),
            axum::http::StatusCode::NOT_FOUND,
            "a missing NewId must fail closed → 404, not fetch"
        );

        // Nothing emitted after the unanswered presence query may be a fetch.
        let mut saw_fetch = false;
        while let Ok(msg) = rx.try_recv() {
            match msg {
                ClientConnection::NewConnection { .. } => saw_fetch = true,
                ClientConnection::Request { req, .. } => {
                    if matches!(
                        req.as_ref(),
                        ClientRequest::ContractOp(ContractRequest::Get { .. })
                    ) {
                        saw_fetch = true;
                    }
                }
            }
        }
        assert!(
            !saw_fetch,
            "a missing NewId must NOT issue a network fetch (#3945 fail-closed)"
        );

        clear_cache(&instance_id).await;
    }

    /// Fail-closed regression for #3945: if the node is gone entirely (the
    /// `ClientConnection` receiver is dropped, so even the presence query's
    /// `NewConnection` send fails), the cold-cache request must 404 and issue
    /// no GET. Covers the `request_sender.send(...).is_err()` branch of
    /// `is_locally_known`.
    #[tokio::test]
    async fn variable_content_fails_closed_when_node_channel_closed() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x49;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        // Drop the receiver immediately so every send on the sender fails.
        let (sender, rx) = request_channel();
        drop(rx);

        let result = variable_content(
            key.clone(),
            format!("/v1/contract/web/{key}/image.jpg"),
            ApiVersion::V1,
            sender,
            &test_webapp_cache(),
        )
        .await
        .map(|r| r.into_response());

        // is_locally_known fails closed → gate skips the fetch → 404 from the
        // empty cache directory. (A dead channel must never surface as a fetch.)
        let response = result.expect("closed-channel cold request must still resolve");
        assert_eq!(
            response.status(),
            axum::http::StatusCode::NOT_FOUND,
            "a closed node channel must fail closed → 404"
        );

        clear_cache(&instance_id).await;
    }

    /// #3945 broaden-signal coverage: a cold cache for a contract that is
    /// SUBSCRIBED but NOT in the store (e.g. the lease outlived LRU eviction)
    /// must still fetch. Proves `is_locally_known`'s OR branch — known =
    /// in-store OR subscribed — not store-presence alone.
    #[tokio::test]
    async fn variable_content_triggers_fetch_for_subscribed_not_stored() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x4a;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                variable_content(
                    key.clone(),
                    format!("/v1/contract/web/{key}/image.jpg"),
                    ApiVersion::V1,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|_| ())
            })
        };

        // Presence query: empty contract_states (NOT stored) but the instance
        // IS in subscriptions → known via the subscription branch.
        answer_presence_query(&mut rx, instance_id, |query_id| {
            let mut diag = empty_diagnostics();
            diag.subscriptions
                .push(freenet_stdlib::client_api::SubscriptionInfo {
                    contract_key: instance_id,
                    client_id: query_id.into(),
                });
            diag
        })
        .await;

        // The gate must let the fetch through.
        expect_fetch_pair(&mut rx, instance_id).await;

        handler.abort();
        clear_cache(&instance_id).await;
    }

    /// #3977-interaction regression for the #3945 cold/warm gate split: a
    /// WARM-but-stale cache for an UNSUBSCRIBED, UNHOSTED contract must still
    /// refresh. The gate is cold-path only, so a warm-but-stale refresh issues
    /// its GET WITHOUT a preceding presence query — even though the contract is
    /// not currently "known". A warm on-disk cache already proves the node
    /// legitimately fetched this contract before, so refreshing it to pick up a
    /// republish (#3977) is not the random-key amplification vector. Without
    /// this split the handler would gate the warm refresh on a presence query
    /// that says "unknown" and serve a stale bundle forever.
    #[tokio::test]
    async fn warm_but_stale_refreshes_without_presence_gate() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x4b;
        let instance_id = ContractInstanceId::new(bytes);
        clear_cache(&instance_id).await;

        // Warm but unreconciled cache (hash present, no refresh timer ⇒ due).
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();

        let (sender, mut rx) = request_channel();
        let handler = tokio::spawn(async move {
            refresh_cache_if_due(instance_id, &sender, &test_webapp_cache())
                .await
                .map(|_| ())
        });

        // The FIRST message must be the fetch's NewConnection — NOT a presence
        // query. `expect_fetch_pair` (the warm variant) asserts exactly that:
        // it would mis-parse a NodeDiagnostics query as the fetch NewConnection
        // and the subsequent Get assertion would fail.
        expect_fetch_pair(&mut rx, instance_id).await;

        handler.abort();
        clear_cache(&instance_id).await;
    }

    /// Companion to `variable_content_triggers_fetch_on_cache_miss`: when the
    /// hash file is present AND the contract was reconciled within the refresh
    /// TTL, the handler must NOT issue a fetch. This pins the cache-hit fast
    /// path and prevents a regression where every subpath request re-fetches.
    #[tokio::test]
    async fn variable_content_skips_fetch_when_cache_present_and_fresh() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x41;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        // Prime the cache marker and a served file.
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(cache_dir.join("image.jpg"), b"fake-jpeg-bytes")
            .await
            .unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();
        // Mark the contract as just-reconciled so it falls inside the TTL window.
        CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());

        let (sender, mut rx) = request_channel();
        let result = variable_content(
            key.clone(),
            format!("/v1/contract/web/{key}/image.jpg"),
            ApiVersion::V1,
            sender,
            &test_webapp_cache(),
        )
        .await;

        let response = result.expect("warm-cache request must succeed");
        let body = response_body(response).await;
        assert_eq!(
            body, "fake-jpeg-bytes",
            "warm-cache path must serve the primed file byte-for-byte"
        );
        assert!(
            rx.try_recv().is_err(),
            "fresh-cache path must not send any NewConnection/Get on the channel"
        );

        // Clean up last so a failed assertion above doesn't leave residue
        // that flips the next run's cold-cache check into warm-cache state.
        clear_cache(&instance_id).await;
    }

    /// Receives the `is_locally_known` (#3945) handshake and asserts it is the
    /// scoped `NodeQueries(NodeDiagnostics)` presence query for `instance_id`.
    ///
    /// Replies to the opening `NewConnection` with a fresh client id, asserts
    /// the diagnostics query is scoped to exactly `instance_id` (no broad
    /// enumeration), sends `reply`, then drains the trailing `Disconnect`. The
    /// reply must use the `query_id` from the request, so it is built by the
    /// caller via the passed closure.
    ///
    /// Leaves the channel positioned at the handler's next message (the real
    /// fetch's `NewConnection`, if the gate let it through).
    async fn answer_presence_query(
        rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
        instance_id: ContractInstanceId,
        build_reply: impl FnOnce(
            crate::client_events::ClientId,
        ) -> freenet_stdlib::client_api::NodeDiagnosticsResponse,
    ) {
        use freenet_stdlib::client_api::{NodeQuery, QueryResponse};

        let new_conn = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
            .await
            .expect("handler must send NewConnection for the local-known query")
            .expect("channel must remain open");
        let callbacks = match new_conn {
            ClientConnection::NewConnection { callbacks, .. } => callbacks,
            other => panic!("local-known query must open with NewConnection, got: {other:?}"),
        };
        callbacks
            .send(HostCallbackResult::NewId {
                id: crate::client_events::ClientId::next(),
            })
            .expect("callback receiver live for query NewId");

        let query = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
            .await
            .expect("handler must send the presence query")
            .expect("channel must remain open");
        let ClientConnection::Request { req, client_id, .. } = query else {
            panic!("expected the NodeDiagnostics request, got: {query:?}");
        };
        if let ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics { config }) = req.as_ref() {
            // The presence query must be scoped to exactly the one contract — a
            // broad/empty `contract_keys` would make the node enumerate ALL
            // hosted contracts on every subresource request.
            assert_eq!(
                config.contract_keys.len(),
                1,
                "presence query must request exactly one contract key"
            );
            assert_eq!(
                *config.contract_keys[0].id(),
                instance_id,
                "presence query must be scoped to the requested instance"
            );
            assert!(
                !config.include_node_info
                    && !config.include_network_info
                    && !config.include_system_metrics
                    && !config.include_detailed_peer_info,
                "presence query must keep the heavy diagnostics flags off"
            );
        } else {
            panic!("local-known query must be NodeQueries(NodeDiagnostics), got: {req:?}");
        }
        let query_id = client_id;
        // The reply rides the SAME `callbacks` sender the handler reads.
        callbacks
            .send(HostCallbackResult::Result {
                id: query_id,
                result: Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(
                    build_reply(query_id),
                ))),
            })
            .expect("callback receiver live for NodeDiagnostics reply");
        // Drain the trailing Disconnect the query helper sends on its way out.
        let _ = rx.recv().await;
    }

    /// A `NodeDiagnosticsResponse` with every optional field empty. Tests fill
    /// in `contract_states` / `subscriptions` to model presence.
    fn empty_diagnostics() -> freenet_stdlib::client_api::NodeDiagnosticsResponse {
        freenet_stdlib::client_api::NodeDiagnosticsResponse {
            node_info: None,
            network_info: None,
            subscriptions: Vec::new(),
            contract_states: std::collections::HashMap::new(),
            system_metrics: None,
            connected_peers_detailed: Vec::new(),
        }
    }

    /// Answers the #3945 presence query as "the node HOSTS/STORES `instance_id`"
    /// — the realistic #3940 cross-contract case: a Delta page `<img>`s a
    /// separate contract X that the node fetched-and-stored when the subresource
    /// was first loaded for some user, but that THIS user never visited at its
    /// root (so X is not in the application-subscription set). The gate must
    /// still let the fetch through on store presence alone.
    async fn answer_presence_query_hosted(
        rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
        instance_id: ContractInstanceId,
    ) {
        answer_presence_query(rx, instance_id, |_query_id| {
            let mut diag = empty_diagnostics();
            // contract_states keyed by ContractKey::Display == instance-id base58.
            diag.contract_states.insert(
                instance_id.to_string(),
                freenet_stdlib::client_api::ContractState {
                    subscribers: 0,
                    subscriber_peer_ids: Vec::new(),
                    size_bytes: 1234,
                },
            );
            diag
        })
        .await;
    }

    /// Drives `serve_sandbox_content` (or `variable_content`) to the point
    /// where it has emitted its `NewConnection` + `Get` pair on the channel,
    /// asserting the contract key on the `Get`, then aborts the in-flight
    /// fetch. Returns once both messages have been observed.
    ///
    /// This is the **warm-but-stale** path: the #3945 presence gate runs ONLY
    /// on a cold cache, so a warm-cache refresh emits the fetch pair directly
    /// with no preceding presence query. Cold-cache tests use
    /// `expect_fetch_pair_cold`, which answers the presence query first.
    ///
    /// Replies to the `NewConnection` callback with a synthetic client id so
    /// the handler progresses past its blocking `NewId` recv to the `Get`.
    async fn expect_fetch_pair(
        rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
        instance_id: ContractInstanceId,
    ) {
        let new_conn = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
            .await
            .expect("handler must send NewConnection when a refresh is due")
            .expect("channel must remain open for the duration of the send");
        let callbacks = match new_conn {
            ClientConnection::NewConnection { callbacks, .. } => callbacks,
            other => panic!("first message must be NewConnection, got: {other:?}"),
        };
        callbacks
            .send(HostCallbackResult::NewId {
                id: crate::client_events::ClientId::next(),
            })
            .expect("callback receiver must be live while handler awaits NewId");

        let get_req = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
            .await
            .expect("handler must follow up with a Get request")
            .expect("channel must remain open");
        match get_req {
            ClientConnection::Request { req, .. } => {
                assert!(
                    matches!(
                        req.as_ref(),
                        ClientRequest::ContractOp(ContractRequest::Get { key: k, .. })
                            if *k == instance_id
                    ),
                    "second message must be Get({instance_id}), got: {req:?}"
                );
            }
            other => panic!("expected ClientConnection::Request, got: {other:?}"),
        }
    }

    /// Cold-cache variant of `expect_fetch_pair`: answers the #3945 presence
    /// query as "node hosts/stores `instance_id`" (the #3940 cross-contract
    /// case) first, then asserts the resulting fetch pair. Use this whenever the
    /// cache is COLD (no `{key}.hash` on disk), where the DoS gate runs.
    async fn expect_fetch_pair_cold(
        rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
        instance_id: ContractInstanceId,
    ) {
        answer_presence_query_hosted(rx, instance_id).await;
        expect_fetch_pair(rx, instance_id).await;
    }

    /// Regression test for #3977. `serve_sandbox_content` (the `?__sandbox=1`
    /// iframe handler) must reconcile the on-disk cache against current network
    /// state, NOT serve blindly from disk.
    ///
    /// Before the fix, this handler only checked `path.exists()` and served the
    /// already-extracted bundle, so a republished contract kept serving the old
    /// bundle on the iframe path until the shell root (`/`) was hit again.
    ///
    /// Here the cache is warm (hash file + index.html on disk) but has never
    /// been reconciled (`CONTRACT_CACHE_REFRESH` has no entry), so a refresh is
    /// due and the handler must emit the `NewConnection` + `Get` fetch pair.
    /// The pre-fix code sent nothing on the channel.
    #[tokio::test]
    async fn serve_sandbox_content_triggers_refresh_when_stale() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x44;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        // Warm but unreconciled cache: hash file present, but no refresh timer.
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(cache_dir.join("index.html"), b"<html>old bundle</html>")
            .await
            .unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();

        let (sender, mut rx) = request_channel();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                serve_sandbox_content(
                    key.clone(),
                    ApiVersion::V1,
                    None,
                    sender,
                    &test_webapp_cache(),
                )
                .await
                .map(|_| ())
            })
        };

        expect_fetch_pair(&mut rx, instance_id).await;

        handler.abort();
        clear_cache(&instance_id).await;
    }

    /// Companion to the above: once `serve_sandbox_content` has reconciled a
    /// contract within the TTL window, a subsequent request must serve from
    /// disk WITHOUT issuing another fetch. Pins the TTL fast path so the iframe
    /// load doesn't do a network round-trip on every request.
    #[tokio::test]
    async fn serve_sandbox_content_skips_refresh_when_fresh() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x45;
        let instance_id = ContractInstanceId::new(bytes);
        let key = instance_id.to_string();
        clear_cache(&instance_id).await;

        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(cache_dir.join("index.html"), b"<html>fresh bundle</html>")
            .await
            .unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();
        // Reconciled just now: inside the TTL window.
        CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());

        let (sender, mut rx) = request_channel();
        let result = serve_sandbox_content(
            key.clone(),
            ApiVersion::V1,
            None,
            sender,
            &test_webapp_cache(),
        )
        .await;

        let response = result.expect("fresh-cache sandbox request must succeed");
        let body = response_body(response).await;
        assert!(
            body.contains("fresh bundle"),
            "fresh-cache path must serve the primed index.html, got: {body}"
        );
        assert!(
            rx.try_recv().is_err(),
            "fresh-cache sandbox path must not send any NewConnection/Get on the channel"
        );

        clear_cache(&instance_id).await;
    }

    /// `refresh_cache_if_due` must treat a refresh timer older than
    /// `CONTRACT_CACHE_REFRESH_TTL` as stale and re-fetch, even when the
    /// on-disk cache is warm. This is the path that picks up a mid-session
    /// republish (#3977 impact 3) once the TTL window elapses.
    ///
    /// Uses paused time so the TTL boundary is crossed deterministically by
    /// `advance()` rather than wall-clock subtraction — `Instant::now()` on a
    /// freshly-booted host can be too close to the monotonic origin for a
    /// `checked_sub(TTL)` to succeed, which would make the test flaky.
    #[tokio::test(start_paused = true)]
    async fn refresh_cache_if_due_refetches_after_ttl_expires() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x46;
        let instance_id = ContractInstanceId::new(bytes);
        clear_cache(&instance_id).await;

        // Warm cache, reconciled "now" (paused clock base).
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();
        CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());

        // Advance past the TTL so the timer reads as stale.
        tokio::time::advance(CONTRACT_CACHE_REFRESH_TTL + Duration::from_secs(1)).await;

        let (sender, mut rx) = request_channel();
        let handler = tokio::spawn(async move {
            refresh_cache_if_due(instance_id, &sender, &test_webapp_cache())
                .await
                .map(|_| ())
        });

        // A stale timer must trigger a fetch despite the warm on-disk cache.
        expect_fetch_pair(&mut rx, instance_id).await;

        handler.abort();
        clear_cache(&instance_id).await;
    }

    /// Services one transient client connection's worth of
    /// `ensure_contract_cached` traffic: replies to `NewConnection` with a
    /// fresh client id, then answers the `Get` with a successful `GetResponse`
    /// whose state hashes to the value already on disk. Because the on-disk
    /// `{key}.hash` matches, `unpack_if_stale` returns early (no `WebApp`
    /// unpack needed), so the refresh succeeds and records the timer.
    ///
    /// Both replies go on the `callbacks` sender from `NewConnection` — that is
    /// the `response_recv` end `ensure_contract_cached` reads from.
    async fn serve_one_get(
        rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
        contract: &ContractContainer,
        state: &WrappedState,
    ) {
        let msg = rx.recv().await.expect("leader must issue NewConnection");
        let callbacks = match msg {
            ClientConnection::NewConnection { callbacks, .. } => callbacks,
            other => panic!("expected NewConnection, got: {other:?}"),
        };
        callbacks
            .send(HostCallbackResult::NewId {
                id: crate::client_events::ClientId::next(),
            })
            .expect("callback receiver live");
        let get = rx.recv().await.expect("Get must follow NewConnection");
        match get {
            ClientConnection::Request { req, .. } => assert!(
                matches!(
                    req.as_ref(),
                    ClientRequest::ContractOp(ContractRequest::Get { .. })
                ),
                "expected Get, got: {req:?}"
            ),
            other => panic!("expected Get request, got: {other:?}"),
        }
        callbacks
            .send(HostCallbackResult::Result {
                id: crate::client_events::ClientId::next(),
                result: Ok(HostResponse::ContractResponse(
                    ContractResponse::GetResponse {
                        key: contract.key(),
                        contract: Some(contract.clone()),
                        state: state.clone(),
                    },
                )),
            })
            .expect("callback receiver live for GetResponse");
        // Drain the trailing Disconnect the handler sends on the way out.
        let _ = rx.recv().await;
    }

    /// Concurrency regression for the Codex review finding on #3977: a fan-out
    /// of simultaneous requests on a warm-but-stale cache must issue exactly
    /// ONE network GET per contract per window, not one per request.
    ///
    /// Runs the real `refresh_cache_if_due` end-to-end. The leader's GET is
    /// answered with a `GetResponse` whose state hash matches the on-disk
    /// `{key}.hash`, so `unpack_if_stale` returns early, the leader records the
    /// refresh timer, and every follower that queued behind the refresh lock
    /// re-checks, sees the fresh timer, and skips its own GET. The receiver
    /// services exactly one `Get`, then asserts the channel closes with no
    /// second `NewConnection`.
    #[tokio::test]
    async fn refresh_cache_if_due_coalesces_concurrent_refreshes() {
        // Derive the instance id FROM a real contract so the GetResponse key
        // matches and `unpack_if_stale` takes its matching-hash early return.
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(vec![1, 2, 3, 4])),
            Parameters::from(vec![5, 6]),
        )));
        let instance_id = *contract.key().id();
        let state = WrappedState::new(vec![9, 9, 9]);
        clear_cache(&instance_id).await;

        // Warm cache whose stored hash matches the state we'll return, so the
        // refresh succeeds without an actual unpack. No fresh timer ⇒ due.
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        let matching_hash = hash_state(state.as_ref());
        tokio::fs::write(state_hash_path(&instance_id), matching_hash.to_be_bytes())
            .await
            .unwrap();

        // Shared channel so a single receiver observes every caller's traffic.
        let (sender, mut rx) = request_channel();
        let mut handlers = Vec::new();
        for _ in 0..8 {
            let sender = sender.clone();
            handlers.push(tokio::spawn(async move {
                refresh_cache_if_due(instance_id, &sender, &test_webapp_cache())
                    .await
                    .map(|_| ())
            }));
        }
        drop(sender); // channel closes once all 8 handlers finish.

        // Warm cache → the #3945 presence gate does NOT run (it is cold-path
        // only). The leader fetches directly; followers coalesce on the refresh
        // lock and re-check the fresh timer, so only the leader issues a GET.
        // Service exactly one GET (the leader's). Every follower coalesces.
        serve_one_get(&mut rx, &contract, &state).await;

        // After the single served GET, no further NewConnection may appear:
        // a second one would mean a follower issued a redundant GET.
        let mut extra = 0;
        while let Some(msg) = rx.recv().await {
            if matches!(msg, ClientConnection::NewConnection { .. }) {
                extra += 1;
            }
        }
        assert_eq!(
            extra, 0,
            "concurrent refreshers must coalesce to a single GET; saw {extra} extra"
        );

        for h in handlers {
            h.await
                .expect("handler must not panic")
                .expect("refresh must succeed");
        }
        clear_cache(&instance_id).await;
    }

    /// Regression for the failure-path invariant: when `ensure_contract_cached`
    /// returns an error, `refresh_cache_if_due` must NOT record a fresh timer,
    /// so the next request retries instead of being suppressed for the TTL.
    ///
    /// Drives a real refresh whose GET is answered with a `contract: None`
    /// `GetResponse` (which `handle_get_response` maps to `MissingContract`),
    /// then asserts the call returned `Err` AND no timer was inserted. This
    /// pins the "timer advances only on success" property the
    /// `CONTRACT_CACHE_REFRESH.insert` placement after the `?` relies on —
    /// hoisting the insert before the GET would silently break retries.
    #[tokio::test]
    async fn refresh_cache_if_due_does_not_record_timer_on_fetch_failure() {
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(vec![7, 7, 7, 7])),
            Parameters::from(vec![8, 8]),
        )));
        let instance_id = *contract.key().id();
        clear_cache(&instance_id).await;

        // Warm but unreconciled cache so a refresh is due (and no timer yet).
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
            .await
            .unwrap();

        let (sender, mut rx) = request_channel();
        let handler = tokio::spawn(async move {
            refresh_cache_if_due(instance_id, &sender, &test_webapp_cache()).await
        });

        // Warm cache → the #3945 presence gate does NOT run; the failure-path
        // GET below is reached directly.
        // Service the GET with a contract: None GetResponse → MissingContract.
        let msg = rx.recv().await.expect("must issue NewConnection");
        let callbacks = match msg {
            ClientConnection::NewConnection { callbacks, .. } => callbacks,
            other => panic!("expected NewConnection, got: {other:?}"),
        };
        callbacks
            .send(HostCallbackResult::NewId {
                id: crate::client_events::ClientId::next(),
            })
            .expect("callback receiver live");
        let _get = rx.recv().await.expect("Get must follow NewConnection");
        callbacks
            .send(HostCallbackResult::Result {
                id: crate::client_events::ClientId::next(),
                result: Ok(HostResponse::ContractResponse(
                    ContractResponse::GetResponse {
                        key: contract.key(),
                        contract: None,
                        state: WrappedState::new(Vec::new()),
                    },
                )),
            })
            .expect("callback receiver live for GetResponse");

        let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
            .await
            .expect("handler must finish promptly")
            .expect("handler must not panic");
        assert!(
            result.is_err(),
            "a None-contract GetResponse must surface as an error, got: {result:?}"
        );
        assert!(
            !CONTRACT_CACHE_REFRESH.contains_key(&instance_id),
            "a failed refresh must NOT record a timer, or the next request would \
             be suppressed for the whole TTL instead of retrying"
        );

        clear_cache(&instance_id).await;
    }

    /// Direct unit test for `handle_get_response`'s `MissingContract`
    /// branch. Refactoring `handle_get_response` introduced this seam as a
    /// pure-logic boundary; covering each arm here catches regressions
    /// without the full async plumbing of an integration test.
    #[tokio::test]
    async fn handle_get_response_maps_none_contract_to_missing_contract_error() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x42;
        let instance_id = ContractInstanceId::new(bytes);

        let key = freenet_stdlib::prelude::ContractKey::from_id_and_code(
            instance_id,
            freenet_stdlib::prelude::CodeHash::new([0u8; 32]),
        );
        let result = handle_get_response(
            instance_id,
            Ok(Some(HostCallbackResult::Result {
                id: crate::client_events::ClientId::next(),
                result: Ok(HostResponse::ContractResponse(
                    ContractResponse::GetResponse {
                        key,
                        contract: None,
                        state: WrappedState::new(Vec::new()),
                    },
                )),
            })),
            &test_webapp_cache(),
        )
        .await;

        assert!(
            matches!(
                result,
                Err(WebSocketApiError::MissingContract { instance_id: id }) if id == instance_id
            ),
            "None-contract GetResponse must surface as MissingContract({instance_id}), got: {result:?}"
        );
    }

    /// Companion to the above: a `tokio::time::error::Elapsed` (30s fetch
    /// timeout) surfaces as an `AxumError(RequestError(Timeout))`, not a panic
    /// or hang.  `WebSocketApiError::into_response` maps this to a 503 with
    /// `<meta http-equiv="refresh">` — see #3472.  We use RequestError(Timeout)
    /// rather than the dual-use OperationError so terminal node OperationErrors
    /// (e.g. banned contracts) are NOT swept into the retry page.
    #[tokio::test]
    async fn handle_get_response_maps_timeout_to_request_timeout() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x43;
        let instance_id = ContractInstanceId::new(bytes);

        // Manufacture an Elapsed by racing an already-expired sleep.
        let elapsed = tokio::time::timeout(
            std::time::Duration::from_millis(0),
            std::future::pending::<()>(),
        )
        .await
        .expect_err("timeout must fire");
        let recv_result: Result<Option<HostCallbackResult>, _> = Err(elapsed);

        let result = handle_get_response(instance_id, recv_result, &test_webapp_cache()).await;
        assert!(
            matches!(
                result,
                Err(WebSocketApiError::AxumError {
                    error: ErrorKind::RequestError(RequestError::Timeout)
                })
            ),
            "30s timeout must map to RequestError(Timeout) (for retry page), got: {result:?}"
        );
    }

    /// A closed response channel (`Ok(None)`, node shutting down) surfaces as
    /// `AxumError(ChannelClosed)` — an unambiguously transient kind that maps
    /// to the 503 retry page (#3472).
    #[tokio::test]
    async fn handle_get_response_maps_channel_closed_to_channel_closed() {
        let mut bytes = [0u8; 32];
        bytes[0] = 0x3a;
        bytes[1] = 0x43;
        let instance_id = ContractInstanceId::new(bytes);

        let recv_result: Result<Option<HostCallbackResult>, tokio::time::error::Elapsed> = Ok(None);

        let result = handle_get_response(instance_id, recv_result, &test_webapp_cache()).await;
        assert!(
            matches!(
                result,
                Err(WebSocketApiError::AxumError {
                    error: ErrorKind::ChannelClosed
                })
            ),
            "closed channel must map to ChannelClosed (for retry page), got: {result:?}"
        );
    }

    /// Extracts the response body as a UTF-8 string for test assertions.
    async fn response_body(resp: impl IntoResponse) -> String {
        let body = resp.into_response();
        let bytes = axum::body::to_bytes(body.into_body(), 1024 * 1024)
            .await
            .unwrap();
        String::from_utf8(bytes.to_vec()).unwrap()
    }

    #[tokio::test]
    async fn root_relative_asset_paths_rewritten() {
        let dir = tempfile::tempdir().unwrap();
        let key = "raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv";
        let html = r#"<!DOCTYPE html>
<html>
    <head>
        <title>Test</title>
    <link rel="preload" as="script" href="/./assets/app.js" crossorigin></head>
    <body><div id="main"></div>
    <script type="module" async src="/./assets/app.js"></script>
    </body>
</html>"#;
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        let expected_href = format!("href=\"/v1/contract/web/{key}/assets/app.js\"");
        assert!(
            result.contains(&expected_href),
            "href not rewritten.\nGot: {result}"
        );

        let expected_src = format!("src=\"/v1/contract/web/{key}/assets/app.js\"");
        assert!(
            result.contains(&expected_src),
            "src not rewritten.\nGot: {result}"
        );

        // Original root-relative paths should be gone
        assert!(
            !result.contains("\"/./assets/"),
            "original /./assets/ paths still present"
        );

        // WebSocket shim should be injected instead of raw auth token
        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected"
        );
    }

    #[tokio::test]
    async fn root_relative_asset_paths_rewritten_v2() {
        let dir = tempfile::tempdir().unwrap();
        let key = "raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv";
        let html = r#"<head><link href="/./assets/app.js"></head><body></body>"#;
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V2, "index.html")
                .await
                .unwrap(),
        )
        .await;

        let expected = format!("href=\"/v2/contract/web/{key}/assets/app.js\"");
        assert!(
            result.contains(&expected),
            "V2 href not rewritten.\nGot: {result}"
        );
        assert!(
            !result.contains("\"/./assets/"),
            "original /./assets/ paths still present in V2"
        );
    }

    #[tokio::test]
    async fn single_quoted_paths_also_rewritten() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        let html = "<head><script src='/./assets/app.js'></script></head>";
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        let expected = format!("'/v1/contract/web/{key}/assets/app.js'");
        assert!(
            result.contains(&expected),
            "single-quoted path not rewritten.\nGot: {result}"
        );
    }

    #[tokio::test]
    async fn paths_without_dot_slash_not_rewritten() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        // Paths like "/assets/app.js" (without /.) should NOT be rewritten,
        // only the Dioxus-specific "/./assets/" pattern is targeted.
        let html = r#"<head><link href="/assets/app.css"></head><body></body>"#;
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        // The /assets/ path should remain unchanged (no /. prefix)
        assert!(
            result.contains("\"/assets/app.css\""),
            "path without /. was incorrectly rewritten.\nGot: {result}"
        );
    }

    #[tokio::test]
    async fn shell_page_iframe_sandbox_allows_downloads() {
        // Regression for freenet/mail#TBD: webapps that emit blob/object-URL
        // downloads via `<a download>` were silently dropped by Chromium
        // and Safari because the iframe sandbox omitted `allow-downloads`.
        // Lock the token in so a future refactor does not regress the fix.
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;
        assert!(
            html.contains("allow-downloads"),
            "iframe sandbox missing `allow-downloads` — user-initiated \
             file downloads from sandboxed webapps will be silently blocked \
             by the browser. Got HTML:\n{html}"
        );
    }

    #[tokio::test]
    async fn shell_page_hosted_mode_renders_proxy_chrome_bar() {
        // The hosted-mode "shell chrome" bar lives OUTSIDE the sandboxed iframe
        // and carries the "not private" disclosure plus the Account popover
        // (access-key backup/restore + export-to-your-own-peer). It must render
        // in hosted mode and be ABSENT in non-hosted mode so a normal
        // single-user node is unaffected.
        let token = AuthToken::generate();
        let hosted = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
        )
        .await;
        assert!(
            hosted.contains(r#"id="fnbar""#),
            "hosted bar missing: {hosted}"
        );
        assert!(
            hosted.contains("not private"),
            "always-visible disclosure missing"
        );
        assert!(
            hosted.contains("Access key") && hosted.contains("Restore from key"),
            "access-key backup/restore controls missing"
        );
        assert!(hosted.contains("Export data"), "export control missing");
        // The export button must be wired to the node export endpoint, not a
        // placeholder. Pin the route so a refactor cannot silently revert it.
        assert!(
            hosted.contains("/v1/hosted/export"),
            "export button is not wired to the export endpoint"
        );
        // The access key is read from the shell-only token global; it is never
        // injected into the sandboxed iframe.
        assert!(
            hosted.contains("__freenet_user_token"),
            "access-key source global missing"
        );

        let plain = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;
        assert!(
            !plain.contains(r#"id="fnbar""#),
            "non-hosted shell must not render the proxy chrome bar"
        );
        assert!(
            !plain.contains("Export data"),
            "non-hosted shell must not render the export control"
        );
    }

    #[tokio::test]
    async fn shell_page_contains_iframe_and_bridge() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;

        // Shell page must contain sandboxed iframe
        assert!(
            html.contains(
                r#"sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals""#
            ),
            "iframe sandbox attribute missing or wrong allowlist"
        );
        // Iframe must grant clipboard via permissions-policy
        assert!(
            html.contains(r#"allow="clipboard-read; clipboard-write""#),
            "iframe permissions-policy missing clipboard grants"
        );
        // Iframe src must include __sandbox=1
        assert!(
            html.contains("__sandbox=1"),
            "iframe src missing __sandbox=1 param"
        );
        // Bridge script must be present
        assert!(
            html.contains("freenetBridge"),
            "bridge script not found in shell page"
        );
        // Auth token must NOT be exposed as window.__FREENET_AUTH_TOKEN__
        assert!(
            !html.contains("__FREENET_AUTH_TOKEN__"),
            "auth token exposed in global variable (security risk)"
        );
        // Auth token should be passed to the bridge function
        assert!(
            html.contains(&format!("freenetBridge(\"{}\")", token.as_str())),
            "auth token not passed to bridge"
        );
        // Default title and favicon must be present
        assert!(
            html.contains("<title>Freenet</title>"),
            "shell page title mismatch"
        );
        assert!(
            html.contains(r#"<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,"#),
            "favicon should use inline data URI, not external URL"
        );
        assert!(
            !html.contains("freenet.org"),
            "shell page must not reference external origins (CORS). This is a \
             plain substring check over the whole rendered page, including the \
             inlined shell_bridge.js — so a COMMENT that merely mentions a \
             freenet.org host trips it too. If that is what you hit, drop the \
             hostname from the comment rather than loosening this assertion."
        );
        // Shell message handler must be present in bridge JS
        assert!(
            html.contains("__freenet_shell__"),
            "bridge JS must handle shell-level messages (title/favicon)"
        );
        // allow-popups-to-escape-sandbox must NOT be present. It was removed because
        // escaped popups gain localhost:7509 origin, allowing malicious web apps to
        // access other apps' data and bypass permission prompts. External links are
        // now opened via the open_url shell bridge message instead. See #1499.
        assert!(
            !html.contains("allow-popups-to-escape-sandbox"),
            "allow-popups-to-escape-sandbox must not be set (security: #1499)"
        );
        // open_url handler must be present in shell bridge JS for external links
        assert!(
            html.contains("open_url"),
            "shell bridge must handle open_url messages for external links"
        );
    }

    /// Regression test for issue #3836: permission prompts must render as an
    /// in-page overlay in the shell DOM, NOT via browser Notifications (which
    /// users block, miss, or dismiss accidentally).
    #[tokio::test]
    async fn shell_page_permission_overlay_present_and_safe() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;

        // Overlay root and accessibility attributes
        assert!(
            html.contains("__freenet_perm_overlay"),
            "permission overlay root element missing from shell JS"
        );
        assert!(
            html.contains("'role', 'dialog'") || html.contains("\"role\", \"dialog\""),
            "overlay must declare role=dialog for a11y"
        );
        assert!(html.contains("aria-modal"), "overlay must set aria-modal");
        // Subscribes to the new SSE endpoint and POSTs back with the response.
        // /permission/pending is still referenced as the bootstrap-on-connect
        // and `resync` reconciliation endpoint, plus the no-EventSource
        // fallback, so the assertion below still holds.
        assert!(
            html.contains("/permission/events"),
            "shell JS must subscribe to /permission/events (SSE)"
        );
        assert!(
            html.contains("/permission/pending"),
            "shell JS must reference /permission/pending for bootstrap/resync"
        );
        assert!(
            html.contains("/respond"),
            "shell JS must POST to /permission/{{nonce}}/respond"
        );
        // The 404 branch is the cross-tab dismissal contract: "another tab
        // answered, hide my card".
        assert!(
            html.contains("r.status === 404"),
            "shell JS must treat 404 on respond as 'already answered' and hide the card"
        );
        // SSE event names the server emits. Pinning these here ensures the
        // shell stays in sync with the gateway's wire format.
        assert!(
            html.contains("'prompt_added'") || html.contains("\"prompt_added\""),
            "shell JS must subscribe to the prompt_added SSE event"
        );
        assert!(
            html.contains("'prompt_removed'") || html.contains("\"prompt_removed\""),
            "shell JS must subscribe to the prompt_removed SSE event"
        );
        // All delegate-controlled strings must go through textContent, never
        // innerHTML — guards against a future refactor re-opening XSS into
        // the trusted shell origin.
        assert!(
            html.contains("function setText(el, text)"),
            "setText helper (textContent-only) missing"
        );
        // Bound the overlay code path by the explicit `perm-overlay-flow`
        // markers in shell_bridge.js, NOT a code anchor. The previous bound
        // (`setInterval(reconcileFromPending` / `EventSource`) stopped SHORT of
        // the SSE `prompt_added`/`prompt_removed` handlers, which ARE part of
        // the prompt-render flow #3836 protects — so a browser Notification
        // reintroduced into an SSE handler would have slipped past this guard.
        // The markers bracket the whole overlay + SSE region so the asserts
        // below scan all of it (#4849 F2).
        let overlay_start = html
            .find("perm-overlay-flow:BEGIN")
            .expect("perm-overlay-flow:BEGIN marker must bracket the overlay flow");
        let overlay_end = html[overlay_start..]
            .find("perm-overlay-flow:END")
            .expect("perm-overlay-flow:END marker must bracket the overlay flow");
        let overlay_slice = &html[overlay_start..overlay_start + overlay_end];
        // The negative asserts below are only meaningful if the slice actually
        // CONTAINS the SSE prompt-render surface. Pin that the marker-bounded
        // region includes the `prompt_added`/`prompt_removed` handlers, so a
        // refactor that moves them past `perm-overlay-flow:END` (shrinking the
        // slice) fails HERE rather than silently making the negative asserts
        // pass vacuously — the exact regression F2 exists to prevent (#4849).
        assert!(
            overlay_slice.contains("'prompt_added'") && overlay_slice.contains("'prompt_removed'"),
            "overlay guard slice must cover the SSE prompt handlers (#4849 F2)"
        );
        assert!(
            !overlay_slice.contains("innerHTML"),
            "overlay code path must not use innerHTML (XSS surface)"
        );

        // The old permission-prompt-via-Notification flow must be gone: the
        // permission OVERLAY code path must not request or construct a browser
        // Notification (#3836 — delegate permission prompts must render as the
        // in-page SSE overlay, never as a browser Notification users
        // block/miss/dismiss). Scoped to `overlay_slice`, NOT the whole shell:
        // browser Notifications are now legitimately used ELSEWHERE in the
        // bridge for new-MESSAGE notifications (a best-effort UX where a
        // missed/dismissed notification is fine, unlike a permission prompt),
        // pinned separately by `bridge_js_notification_proxy_invariants`. The
        // message-notification code sits well before the overlay root, so it is
        // outside this slice.
        assert!(
            !overlay_slice.contains("Notification.requestPermission"),
            "permission overlay must not request browser Notification permission (#3836)"
        );
        assert!(
            !overlay_slice.contains("new Notification("),
            "permission overlay must not construct a browser Notification (#3836)"
        );
        assert!(
            !html.contains("window.open('/permission/")
                && !html.contains("window.open(\"/permission/"),
            "shell must no longer open /permission/{{nonce}} as a popup (#3836)"
        );
        // The visibility-gated polling loop has been replaced by SSE. SSE
        // pushes regardless of tab visibility, so the visibility-skip code
        // path that caused the originating tab to silently miss prompts
        // when in the background MUST NOT be reintroduced. Pin this
        // contract by asserting `visibilityState` no longer appears in the
        // overlay path. If a future change needs visibility gating for some
        // *other* reason, that change must move this assertion or replace
        // the visibility-related JS with a deliberate no-op rather than
        // bringing back the polling-skip loop.
        assert!(
            !html.contains("visibilityState"),
            "overlay must not gate on document.visibilityState; \
             visibility-skip caused background tabs to miss prompts (SSE replaces polling)"
        );

        // Regression test for issue #3857: the overlay must read the new
        // tagged `caller` JSON shape and render the same Delegate /
        // Technical details treatment as the standalone /permission/{nonce}
        // page. A previous version of this code read `p.contract_id` and
        // fell through to "Unknown" — which silently re-shipped the bug
        // for the in-page overlay path even after the standalone page was
        // fixed. Tests below pin every replacement contract:
        //   1. The "Delegate says:" authorship label must survive (codex
        //      review point 2: removing it is a UX/security regression).
        //   2. The truncated-hash helper and tagged-caller formatter must
        //      both be present in the JS.
        //   3. The old `p.contract_id` field name must be gone.
        //   4. The old `<dl class="fn-ctx">` container must be gone.
        //   5. The new `formatCaller` helper must handle "webapp", "none",
        //      and unknown-kind variants so a future MessageOrigin variant
        //      (issue #3860) doesn't render as a bogus identity.
        assert!(
            html.contains("'Delegate says:'") || html.contains("\"Delegate says:\""),
            "shell overlay must render the 'Delegate says:' authorship label (#3857)"
        );
        assert!(
            html.contains("function truncateHash("),
            "shell overlay must define a truncateHash helper for the new disclosure (#3857)"
        );
        assert!(
            html.contains("function formatCaller("),
            "shell overlay must define a formatCaller helper for the tagged caller object (#3857)"
        );
        assert!(
            html.contains("p.caller"),
            "shell overlay must read p.caller from /permission/pending (#3857)"
        );
        assert!(
            !html.contains("p.contract_id"),
            "shell overlay must not read the removed p.contract_id field (#3857)"
        );
        assert!(
            !html.contains("'fn-ctx'") && !html.contains("\"fn-ctx\""),
            "shell overlay must not build the removed <dl class=\"fn-ctx\"> container (#3857)"
        );
        assert!(
            html.contains("'Freenet app '") || html.contains("\"Freenet app \""),
            "formatCaller must render webapp callers as 'Freenet app <hash>' (#3857)"
        );
        assert!(
            html.contains("'No app caller'") || html.contains("\"No app caller\""),
            "formatCaller must render the None / no-app case as 'No app caller' (#3857)"
        );
        assert!(
            html.contains("'Unknown caller'") || html.contains("\"Unknown caller\""),
            "formatCaller must have a forward-compatible fallback for unknown caller kinds (#3857)"
        );
        // The Technical details disclosure is the one the standalone page
        // also exposes; the overlay must mirror it so both code paths show
        // the user the same information.
        assert!(
            html.contains("'Technical details'") || html.contains("\"Technical details\""),
            "shell overlay must include a 'Technical details' disclosure (#3857)"
        );
        // The inline truncated delegate line is the always-visible passive
        // anomaly signal (codex review point 3). It must appear above the
        // Technical details disclosure, not only inside it.
        assert!(
            html.contains("'fn-delegate-line'") || html.contains("\"fn-delegate-line\""),
            "shell overlay must render the inline truncated delegate hash line (#3857)"
        );
    }

    /// Regression test: the iframe must use data-src (not src) so JS can build
    /// the final URL with the hash fragment before triggering the first load.
    /// Previously, src was set in HTML and the hash was sent via postMessage on
    /// the load event, but WASM apps hadn't registered their listener yet.
    /// See: #3747 (comment)
    #[tokio::test]
    async fn shell_page_iframe_uses_data_src_for_deep_linking() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;

        // The iframe must NOT have a src attribute (which would trigger an
        // immediate load before JS can append the hash fragment).
        assert!(
            !html.contains(
                r#"<iframe id="app" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="#
            ),
            "iframe must use data-src, not src, to avoid loading before JS appends the hash"
        );
        // The iframe must have data-src with the sandbox URL.
        assert!(
            html.contains("data-src=\"/"),
            "iframe must have data-src attribute for JS to read"
        );
    }

    #[tokio::test]
    async fn shell_page_forwards_query_params_to_iframe() {
        let token = AuthToken::generate();
        let qs = Some("invitation=abc123&room=test".to_string());
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
        )
        .await;

        // Query params should be forwarded to iframe src
        assert!(
            html.contains("invitation=abc123"),
            "invitation param not forwarded to iframe"
        );
        assert!(
            html.contains("room=test"),
            "room param not forwarded to iframe"
        );
        // __sandbox=1 must always be first
        assert!(
            html.contains("?__sandbox=1&"),
            "__sandbox=1 not first in iframe params"
        );
    }

    /// Regression test for #3841 (deep-link reload). When a sub-path is
    /// threaded into shell generation, the iframe's `data-src` must point
    /// at that sub-page (`/v1/contract/web/KEY/news/?__sandbox=1`) so the
    /// in-iframe webapp starts on the requested route. Before the fix the
    /// shell always pointed the iframe at the contract root, so reloading
    /// a deep link silently dropped the user back at `/`.
    #[tokio::test]
    async fn shell_page_embeds_sub_path_in_iframe_data_src() {
        let token = AuthToken::generate();

        // Directory-style deep link.
        let html = response_body(
            shell_page(
                &token,
                "testkey123",
                ApiVersion::V1,
                None,
                Some("news/"),
                false,
            )
            .unwrap(),
        )
        .await;
        assert!(
            html.contains(r#"data-src="/v1/contract/web/testkey123/news/?__sandbox=1""#),
            "iframe data-src must carry the sub-path; got: {html}"
        );

        // Nested extensionless deep link.
        let html = response_body(
            shell_page(
                &token,
                "testkey123",
                ApiVersion::V1,
                None,
                Some("about/team"),
                false,
            )
            .unwrap(),
        )
        .await;
        assert!(
            html.contains(r#"data-src="/v1/contract/web/testkey123/about/team?__sandbox=1""#),
            "iframe data-src must carry the nested sub-path; got: {html}"
        );

        // `None` sub-path keeps the iframe pointed at the contract root —
        // pins that the new parameter does not change root-load behaviour.
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;
        assert!(
            html.contains(r#"data-src="/v1/contract/web/testkey123/?__sandbox=1""#),
            "root load must still point the iframe at the contract root; got: {html}"
        );
    }

    /// The sub-path is interpolated into the iframe URL's path component,
    /// so query/fragment delimiters, control characters, and `..`/`.`
    /// traversal segments must be rejected before they can corrupt the
    /// `data-src` URL (or, once the browser HTML-unescapes the attribute,
    /// the surrounding markup) or — for `..` — be normalized by the
    /// browser into a different contract's prefix.
    #[test]
    fn sanitize_shell_sub_path_accepts_safe_paths_and_rejects_dangerous() {
        // Safe relative paths used by real multi-page webapps.
        for ok in ["news/", "about/team", "page2", "index.html", "a/b/c/"] {
            assert_eq!(
                sanitize_shell_sub_path(ok).unwrap(),
                ok,
                "{ok} must be accepted unchanged"
            );
        }

        // `..`/`.` segments MUST be rejected (Codex review, #3841): the
        // browser collapses dot-segments in a URL *before* requesting the
        // iframe, so `/v1/contract/web/KEY/../OTHER/` would be normalized
        // to `/v1/contract/web/OTHER/` and load a different contract under
        // the current shell's token. The later `sandbox_content_body`
        // canonicalization never sees the un-normalized traversal, so this
        // guard is the only layer that can stop it.
        for traversal in ["..", "../other", "a/../b", "a/..", "a/./b", "."] {
            assert!(
                matches!(
                    sanitize_shell_sub_path(traversal),
                    Err(WebSocketApiError::InvalidParam { .. })
                ),
                "{traversal:?} (dot-segment) must be rejected"
            );
        }

        // Dangerous inputs that would break out of the URL path component
        // or inject into the attribute/markup must be rejected.
        for bad in [
            "/absolute",        // leading slash escapes the contract prefix
            "news/?evil=1",     // `?` starts a query, corrupting __sandbox=1
            "news/#frag",       // `#` starts a fragment
            "a b",              // whitespace
            "x\r\nInjected: y", // CRLF (header/markup injection surface)
            "back\\slash",      // backslash (browsers may treat as `/`)
            "tab\tafter",       // control char
        ] {
            assert!(
                matches!(
                    sanitize_shell_sub_path(bad),
                    Err(WebSocketApiError::InvalidParam { .. })
                ),
                "{bad:?} must be rejected"
            );
        }
    }

    /// End-to-end regression for #3841: a deep-link reload routed through
    /// `contract_home` (the path `web_subpages` takes for a top-level
    /// document load of a sub-page) must fetch/cache the contract AND
    /// produce a shell whose iframe loads the requested sub-page, not the
    /// contract root. Drives the real `ensure_contract_cached` cycle via
    /// `serve_one_get`, then inspects the rendered shell HTML.
    #[tokio::test]
    async fn contract_home_with_sub_path_renders_shell_for_that_page() {
        let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
            Arc::new(ContractCode::from(vec![3, 1, 8, 4, 1])),
            Parameters::from(vec![3, 8, 4, 1]),
        )));
        let instance_id = *contract.key().id();
        let key = instance_id.to_string();
        let state = WrappedState::new(vec![4, 2]);
        clear_cache(&instance_id).await;

        // Warm cache whose stored hash matches the state the served GET
        // returns, so `unpack_if_stale` takes its matching-hash early
        // return and the refresh succeeds without a real WebApp unpack.
        let cache_dir = contract_web_path(&instance_id);
        tokio::fs::create_dir_all(&cache_dir).await.unwrap();
        let matching_hash = hash_state(state.as_ref());
        tokio::fs::write(state_hash_path(&instance_id), matching_hash.to_be_bytes())
            .await
            .unwrap();

        let (sender, mut rx) = request_channel();
        let token = AuthToken::generate();
        let handler = {
            let key = key.clone();
            tokio::spawn(async move {
                contract_home(
                    key,
                    sender,
                    token,
                    ApiVersion::V1,
                    None,
                    Some("news/"),
                    false,
                    &test_webapp_cache(),
                )
                .await
                .map(|resp| resp.into_response())
            })
        };

        // Service the fetch the shell render triggers.
        serve_one_get(&mut rx, &contract, &state).await;

        let resp = handler
            .await
            .expect("contract_home task must not panic")
            .expect("contract_home must succeed once the GET is served");
        let html = response_body(resp).await;
        assert!(
            html.contains(&format!(
                r#"data-src="/v1/contract/web/{key}/news/?__sandbox=1""#
            )),
            "deep-link shell iframe must load the sub-page; got: {html}"
        );

        clear_cache(&instance_id).await;
    }

    #[tokio::test]
    async fn sandbox_content_injects_shims_not_auth_token() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        let html = r#"<!DOCTYPE html><html><head></head><body>Hello</body></html>"#;
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        // WS shim must be injected
        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected"
        );
        assert!(
            result.contains("window.WebSocket = FreenetWebSocket"),
            "WebSocket override not set"
        );
        // Navigation interceptor must be injected alongside WebSocket shim
        assert!(
            result.contains("type: 'navigate'"),
            "navigation interceptor not injected"
        );
        // Auth token must NOT appear in sandbox content
        assert!(
            !result.contains("__FREENET_AUTH_TOKEN__"),
            "auth token leaked into sandbox content"
        );
    }

    #[tokio::test]
    async fn ws_shim_injected_without_head_tag() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        // HTML with <body> but no </head> tag
        let html = "<body><div>Hello</div></body>";
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected when no </head> tag"
        );
        // Shim should appear before <body
        let shim_pos = result.find("FreenetWebSocket").unwrap();
        let body_pos = result.find("<body").unwrap();
        assert!(
            shim_pos < body_pos,
            "shim should be injected before <body> tag"
        );
    }

    #[tokio::test]
    async fn ws_shim_injected_in_minimal_html() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        // Minimal HTML with no <head> or <body> tags
        let html = "<div>Hello World</div>";
        std::fs::write(dir.path().join("index.html"), html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
                .await
                .unwrap(),
        )
        .await;

        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected in minimal HTML"
        );
        // Shim should be prepended (appears before the content)
        assert!(
            result.starts_with("<script>"),
            "shim should be prepended to content when no head/body tags"
        );
    }

    #[tokio::test]
    async fn shell_page_strips_sandbox_prefixed_params() {
        let token = AuthToken::generate();
        let qs = Some("__sandbox_extra=evil&invitation=abc&__sandboxFoo=bar".to_string());
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
        )
        .await;

        // __sandbox-prefixed params must be stripped
        assert!(
            !html.contains("__sandbox_extra"),
            "__sandbox_extra param should be stripped"
        );
        assert!(
            !html.contains("__sandboxFoo"),
            "__sandboxFoo param should be stripped"
        );
        // Normal params should be forwarded
        assert!(
            html.contains("invitation=abc"),
            "normal param should be forwarded"
        );
    }

    /// Regression test for the cross-contract `authToken` injection
    /// surface raised in review. A crafted cross-contract link with
    /// `?authToken=attacker_value` reaches `shell_page` via the
    /// `resolved.search` passthrough in the navigate bridge (or via a
    /// pasted deep link that the subpage redirect forwards). The
    /// iframe URL must never carry an attacker-supplied `authToken`
    /// because any webapp that reads credentials from
    /// `location.search` (Delta, River) would pick it up and use it
    /// as its WebSocket credential.
    #[tokio::test]
    async fn shell_page_strips_auth_token_from_forwarded_query() {
        let token = AuthToken::generate();
        let qs = Some("authToken=attacker_value&invite=abc&authTokenExtra=x".to_string());
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
        )
        .await;
        assert!(
            !html.contains("attacker_value"),
            "attacker-supplied authToken value must not reach iframe src"
        );
        assert!(
            !html.contains("authTokenExtra"),
            "authToken-prefixed params must also be stripped"
        );
        assert!(
            html.contains("invite=abc"),
            "harmless params must still be forwarded"
        );
        // The only authToken in the resulting HTML is the
        // freshly-generated one passed to `freenetBridge(authToken)`,
        // not a query-string value in the iframe src.
        assert!(
            html.contains(&format!("freenetBridge(\"{}\"", token.as_str())),
            "shell must still bind the freshly-generated auth token"
        );
    }

    #[tokio::test]
    async fn shell_page_escapes_html_in_query_params() {
        let token = AuthToken::generate();
        let qs = Some("foo=\"><script>alert(1)</script>".to_string());
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
        )
        .await;

        // The double quote and angle brackets must be escaped
        assert!(
            !html.contains("\"><script>alert"),
            "unescaped HTML injection in iframe src"
        );
        assert!(
            html.contains("&quot;"),
            "double quote should be HTML-escaped"
        );
    }

    /// Hosted mode (P2-frontend of #4381): the shell page must mint/load a
    /// durable per-user token in `localStorage` and hand it to the bridge as a
    /// second argument, so the proxied WebSocket upgrade carries
    /// `?userToken=<token>`.
    #[tokio::test]
    async fn shell_page_hosted_mode_injects_user_token() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
        )
        .await;

        // The localStorage token-minting snippet must be present.
        assert!(
            html.contains("__freenet_user_token__"),
            "hosted-mode shell must include the durable localStorage token key; got: {html}"
        );
        assert!(
            html.contains("crypto.getRandomValues"),
            "hosted-mode token must be minted from crypto.getRandomValues, not request input"
        );
        assert!(
            html.contains("localStorage.setItem"),
            "hosted-mode token must be persisted to localStorage"
        );
        // New identities must mint a base58 access key: the shell must carry the
        // inline base58 encoder and the Bitcoin/bs58 alphabet, and must NOT use
        // the old hex encoding (`toString(16)`). The server hashes the raw token
        // string, so a previously stored hex token still works — this only pins
        // the format newly minted tokens take. See shell_user_token.js.
        assert!(
            html.contains("base58Encode")
                && html.contains("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),
            "hosted-mode token must be minted as base58 via the inline encoder; got: {html}"
        );
        assert!(
            !html.contains("toString(16)"),
            "hosted-mode token must no longer be hex-encoded (toString(16)); got: {html}"
        );
        // The bridge must be called with the user-token argument AND the
        // hosted-mode flag (so it can fail closed over http).
        assert!(
            html.contains(&format!(
                "freenetBridge(\"{}\", __freenet_user_token, true);",
                token.as_str()
            )),
            "hosted-mode shell must call freenetBridge with the user token and hosted flag; got: {html}"
        );
        // The bridge must NOT be called in the 1-arg form in hosted mode.
        assert!(
            !html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
            "hosted-mode shell must not emit the 1-arg freenetBridge call"
        );
    }

    /// Non-hosted mode must be byte-for-byte the pre-#4381 shell: no token
    /// snippet, the original 1-arg `freenetBridge(...)` call, and no `userToken`
    /// string anywhere. This is the no-regression guard for the default path.
    #[tokio::test]
    async fn shell_page_non_hosted_mode_omits_user_token() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;

        // The per-user-token MINTING machinery (the localStorage snippet and
        // its `__freenet_user_token` variable) must be absent: a non-hosted
        // visitor never gets a durable identity. Note the always-injected
        // `SHELL_BRIDGE_JS` still *mentions* `userToken` as an inert, undefined
        // closure argument guarded by `if (userToken)`, so we deliberately do
        // not assert the substring `userToken` is wholly absent — we assert the
        // minting snippet and the 2-arg call (the parts that actually activate
        // the feature) are absent.
        assert!(
            !html.contains("__freenet_user_token"),
            "non-hosted shell must not mint a per-user token; got: {html}"
        );
        // NB: the always-injected bridge legitimately calls `localStorage.setItem`
        // for per-contract notification preferences (consent / snooze — see
        // `bridge_js_notification_proxy_invariants`). That is NOT a per-user
        // identity token, so we do not blanket-ban `setItem` here; the
        // token-persistence guard is the absence of the token key
        // (`__freenet_user_token`, above) and of the 2-arg bridge call (below).
        assert!(
            !html.contains(", __freenet_user_token)"),
            "non-hosted shell must not call freenetBridge with a user token"
        );
        // The original single-argument bridge call must be emitted unchanged
        // (byte-for-byte the pre-#4381 output).
        assert!(
            html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
            "non-hosted shell must emit the original 1-arg freenetBridge call; got: {html}"
        );
    }

    /// Pins the shell's peer-restart recovery, which is driven AUTONOMOUSLY by
    /// the node's trusted stale-token close code (PR #4781, server-4401 design).
    /// On a node restart the shell's in-memory auth token is invalidated; the
    /// node answers the reconnecting WebSocket with application close code 4401
    /// (`AUTH_TOKEN_INVALID_CLOSE_CODE`) and closes it. The shell — which owns
    /// the WS and the token — sees that close and re-fetches THIS shell HTML
    /// (minting a fresh token) with a cache-busting top-level `location.replace`.
    /// The shell does NOT depend on the sandboxed app asking (the old, spoofable
    /// `type:'reload'` message path is removed).
    #[test]
    fn bridge_js_reloads_shell_on_auth_token_invalid_close() {
        assert!(
            SHELL_BRIDGE_JS.contains("code === 4401 && !clientClosed"),
            "shell must recover on the node's trusted stale-token close code (4401)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("if (isTrustedStaleTokenClose(e.code, ws._clientClosed))"),
            "recovery must trigger on a SERVER-initiated 4401 close only"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("triggerRecoveryReload()"),
            "the 4401 close must drive the autonomous recovery reload"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("location.replace(decision.url)"),
            "recovery must be a cache-busting top-level navigation (location.replace)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("_freload"),
            "recovery must cache-bust so a stale cached shell (dead token) can't loop"
        );
        // The untrusted, spoofable iframe-initiated reload path must be GONE.
        assert!(
            !SHELL_BRIDGE_JS.contains("msg.type === 'reload'"),
            "the iframe-initiated reload trigger must be removed (recovery is \
             driven by the node's trusted close code, not the app's say-so)"
        );
    }

    /// Drift pin: the JS recovery guard hardcodes the literal `4401`, but the
    /// SERVER side that emits the close frame uses the Rust constant
    /// `AUTH_TOKEN_INVALID_CLOSE_CODE` as its single source of truth. Nothing
    /// but this test ties the two together, so a future change to the constant
    /// would silently break shell recovery (the node would close with a new
    /// code the JS no longer recognizes). This assertion FAILS if they drift.
    #[test]
    fn bridge_js_close_code_matches_rust_constant() {
        use crate::client_events::websocket::AUTH_TOKEN_INVALID_CLOSE_CODE;
        assert!(
            SHELL_BRIDGE_JS.contains(&format!(
                "code === {AUTH_TOKEN_INVALID_CLOSE_CODE} && !clientClosed"
            )),
            "shell_bridge.js must gate recovery on the server's \
             AUTH_TOKEN_INVALID_CLOSE_CODE ({AUTH_TOKEN_INVALID_CLOSE_CODE}); the JS literal \
             drifted from the Rust constant"
        );
    }

    /// Pins the two safeguards on the recovery reload (PR #4781 review, MAJOR #2):
    /// (1) it is UNFORGEABLE — a sandboxed contract cannot manufacture the 4401
    /// trigger by asking the shell to close its own socket, because the close
    /// proxy marks iframe-initiated closes (`_clientClosed`) and clamps any
    /// app-range (4000-4999) code the iframe requests; and (2) the reload cap is
    /// FAIL-CLOSED and storage-independent — it lives in the `_freload` URL param
    /// (not writable by the contract, always present even in private mode), so a
    /// loop is bounded even when sessionStorage is unavailable.
    #[test]
    fn bridge_js_recovery_reload_is_unforgeable_and_bounded() {
        // Unforgeable: iframe-requested closes are marked (`_clientClosed`) so the
        // trusted-close decision rejects them, AND their app-range codes are
        // clamped so 4401 can't even surface to onclose.
        assert!(
            SHELL_BRIDGE_JS.contains("ws._clientClosed = true"),
            "the close proxy must mark iframe-initiated closes so 4401 isn't trusted from them"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("function isTrustedStaleTokenClose(")
                && SHELL_BRIDGE_JS.contains("code === 4401 && !clientClosed"),
            "recovery must only trust a server-initiated 4401 close (not iframe-initiated)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("function clampProxiedCloseCode(")
                && SHELL_BRIDGE_JS.contains("code >= 4000 && code <= 4999"),
            "the close proxy must clamp app-range close codes the iframe requests"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("ws.close(clampProxiedCloseCode(msg.code), msg.reason)"),
            "the close proxy must apply the clamp to the iframe-requested code"
        );
        // Fail-closed, storage-independent cap keyed on the top-document URL.
        assert!(
            SHELL_BRIDGE_JS.contains("function reloadUrlCapDecision("),
            "the reload cap must be computed from the URL (storage-independent, fail-closed)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("reloadUrlCapDecision(location.href, Date.now())"),
            "recovery must consult the URL-param cap before reloading"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("count >= MAX"),
            "the URL cap must refuse once the per-window reload count is reached"
        );
    }

    /// Pins that the per-user-token machinery is wired through the bridge JS
    /// itself (not just the page wrapper): the WS-open handler must append the
    /// `userToken` query param to the real WebSocket URL when a token is set,
    /// and `SHELL_USER_TOKEN_JS` must mint it from OS entropy.
    #[test]
    fn bridge_js_appends_user_token_param() {
        assert!(
            SHELL_BRIDGE_JS.contains("function freenetBridge(authToken, userToken, hostedMode)"),
            "bridge function must accept the per-user token and hosted-mode arguments"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("u.searchParams.set('userToken', userToken)"),
            "bridge must append userToken to the real WebSocket URL"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("if (userToken"),
            "bridge must only append userToken when present (non-hosted = undefined)"
        );
        assert!(
            SHELL_USER_TOKEN_JS.contains("crypto.getRandomValues"),
            "user-token snippet must mint the token from OS entropy"
        );
        assert!(
            SHELL_USER_TOKEN_JS.contains("__freenet_user_token__"),
            "user-token snippet must persist under the durable localStorage key"
        );
    }

    /// freenet/river#408: the browser-notification proxy carries several
    /// security-relevant invariants (a sandboxed contract app hands notifications
    /// to the real-origin shell over the postMessage bridge). Pin them by source
    /// so a refactor can't silently drop them — same discipline as the other
    /// `SHELL_BRIDGE_JS.contains` guards above.
    #[test]
    fn bridge_js_notification_proxy_invariants() {
        // Consent key is derived ONLY from the trusted server-routed path, never
        // from message content, and matches BOTH API versions so a v2 load isn't
        // stranded (permission granted but every notification silently dropped).
        assert!(
            SHELL_BRIDGE_JS.contains(r"/\/v[12]\/contract\/web\/([^/?#]+)/"),
            "notification consent key must derive from the /v[12]/contract/web/<key> path"
        );
        // Every notification is gated on BOTH the browser permission AND this
        // contract's own consent, so one contract's gateway-wide browser grant
        // can't notify the user on behalf of a different contract.
        assert!(
            SHELL_BRIDGE_JS
                .contains("Notification.permission !== 'granted' || !contractHasConsent()"),
            "showAppNotification must gate on browser permission AND per-contract consent"
        );
        // "Not now" must be durable so a contract that re-sends the enable prompt
        // can't re-pin the host-owned bar over the app.
        assert!(
            SHELL_BRIDGE_JS.contains("isNotifySnoozed()")
                && SHELL_BRIDGE_JS.contains("setNotifySnoozed()"),
            "notification dismissal must be enforced via the snooze guard"
        );
        // Notifications pass a rate limiter (per-tag + rolling global cap) so a
        // consented contract can't flood the user with OS notifications.
        assert!(
            SHELL_BRIDGE_JS.contains("notifyLimiter.ok("),
            "notifications must pass the per-tag + global rate limiter"
        );
        // Attacker-controlled notification text is length-capped (text-only).
        assert!(
            SHELL_BRIDGE_JS.contains("String(msg.title).slice(0, 128)"),
            "notification title must be length-capped"
        );
        // The permission prompt is only fired from a real click on the shell
        // affordance (transient activation must come from the shell frame).
        assert!(
            SHELL_BRIDGE_JS.contains("Notification.requestPermission(done)"),
            "permission prompt must be requested from the shell affordance click"
        );
    }

    /// Reading `navigator.serviceWorker` throws a SecurityError in a sandboxed
    /// document without 'allow-same-origin': the property exists on Navigator
    /// (so an `'serviceWorker' in navigator` feature-check passes) but its
    /// GETTER throws. In 0.2.107 the eager installNotifyClickListener() call
    /// read it unguarded; the uncaught throw killed freenetBridge before its
    /// message handlers installed and every locally-served web app hung
    /// (#4945). All serviceWorker access must go through the try/catch
    /// accessor.
    #[test]
    fn bridge_js_service_worker_reads_survive_sandboxed_navigator() {
        assert!(
            SHELL_BRIDGE_JS.contains("function serviceWorkerOrNull()"),
            "the try/catch serviceWorker accessor must exist"
        );
        let body_start = SHELL_BRIDGE_JS
            .find("function serviceWorkerOrNull()")
            .unwrap();
        let body = &SHELL_BRIDGE_JS[body_start..body_start + 400];
        assert!(
            body.contains("try {") && body.contains("catch"),
            "serviceWorkerOrNull must guard the navigator.serviceWorker read with try/catch"
        );
        // Outside the accessor, `navigator.serviceWorker` may appear only as
        // the (already try-guarded) register call pinned by the mobile test
        // below — any new access must route through serviceWorkerOrNull().
        assert_eq!(
            SHELL_BRIDGE_JS.matches("navigator.serviceWorker").count(),
            2,
            "raw navigator.serviceWorker reads outside serviceWorkerOrNull() and \
             the try-guarded register call reintroduce the #4945 sandbox crash"
        );
        // The `in`-operator feature check is exactly the pattern that passed in
        // the sandbox and then blew up on read — it must not come back.
        assert!(
            !SHELL_BRIDGE_JS.contains("'serviceWorker' in navigator"),
            "feature-detect by attempting the read (serviceWorkerOrNull), not via `in`"
        );
    }

    /// Mobile browsers reject the page-level `new Notification()` constructor, so
    /// the shell must show notifications via a service worker's
    /// `showNotification()`. Pin the wiring by source so a refactor can't
    /// silently drop it and re-break mobile notifications.
    #[test]
    fn bridge_js_registers_notification_service_worker() {
        // The shell registers the same-origin notification service worker.
        assert!(
            SHELL_BRIDGE_JS.contains("NOTIFY_SW_URL = '/freenet-notify-sw.js'")
                && SHELL_BRIDGE_JS.contains("navigator.serviceWorker.register(NOTIFY_SW_URL)"),
            "shell must register the /freenet-notify-sw.js service worker"
        );
        // It falls back to showNotification() — the only path that works on
        // mobile, where `new Notification()` throws.
        assert!(
            SHELL_BRIDGE_JS.contains("reg.showNotification("),
            "shell must show notifications via the service worker on mobile"
        );
        // Desktop is UNCHANGED: the page-level constructor is still used, under
        // the same length cap. (The service worker only engages when it throws.)
        assert!(
            SHELL_BRIDGE_JS.contains("new Notification(title, opts)"),
            "desktop must still use the page-level Notification constructor"
        );
        // Constructor-FIRST ordering: the page-level constructor must appear
        // BEFORE the showNotification fallback. A refactor that inverts them
        // (SW-first) would silently switch desktop to SW-shown notifications and
        // onto the click-forwarding path — this catches it.
        let ctor = SHELL_BRIDGE_JS
            .find("new Notification(title, opts)")
            .expect("constructor call present");
        let sw_show = SHELL_BRIDGE_JS
            .find("reg.showNotification(")
            .expect("showNotification fallback present");
        assert!(
            ctor < sw_show,
            "the page-level constructor must be tried BEFORE the service-worker fallback"
        );
        // Click-routing tag contract: the shell writes the routing tag as
        // `fnTag` in notification data; the worker reads `data.fnTag` (pinned in
        // client_api.rs). A rename on the shell side silently breaks routing.
        assert!(
            SHELL_BRIDGE_JS.contains("fnTag: routeTag"),
            "shell must put the routing tag in notification data as fnTag"
        );
        // When neither the constructor nor the worker can display it, the app is
        // told so it can rely on the in-app unread badge.
        assert!(
            SHELL_BRIDGE_JS.contains("notifyStatusToIframe('undeliverable')"),
            "must report 'undeliverable' when neither the constructor nor the worker can show it"
        );
        // The worker's click (which fires in the worker, not the page) is
        // forwarded to the iframe as the same `notification_click` message.
        assert!(
            SHELL_BRIDGE_JS.contains("__freenet_notify_click__"),
            "the worker's notification click must be forwarded to the iframe"
        );
        // Registration is gated on a secure context, since it fails on a plain
        // http (non-localhost) origin — the desktop constructor covers that.
        assert!(
            SHELL_BRIDGE_JS.contains("window.isSecureContext"),
            "service worker registration must be gated on a secure context"
        );
        // The click-forward listener is a standalone function installed EAGERLY
        // at startup (not only on lazy registration), so a click on a persistent
        // notification that outlived a shell reload is still delivered. Pinned so
        // a refactor can't fold it back into ensureNotifyServiceWorker only.
        assert!(
            SHELL_BRIDGE_JS.contains("function installNotifyClickListener("),
            "the SW click-forward listener must be a standalone, eagerly-installed function"
        );
        // It must be CALLED at BOTH sites: lazily inside ensureNotifyServiceWorker
        // AND eagerly at shell startup. Assert two call sites by count, so
        // removing the eager call — reverting to lazy-only installation and
        // reintroducing the "click lost after reload" bug this fixes — fails
        // this test. (The `function installNotifyClickListener() {` definition
        // is `…()` + ` {`, not `…();`, so it isn't counted here.)
        assert!(
            SHELL_BRIDGE_JS
                .matches("installNotifyClickListener();")
                .count()
                >= 2,
            "installNotifyClickListener() must be called BOTH lazily and eagerly at startup"
        );
    }

    /// Regression for #4849: the notification-proxy flood-cap (the rolling
    /// global window in `makeNotifyRateLimiter`) must be PERSISTED per-contract
    /// so a full page reload can't reset it. Without this, a consented contract
    /// could fire the whole budget, force a reload (a same-contract v1<->v2
    /// `navigate`, which the shell reloads as cross-contract), and start over
    /// with an empty limiter. The behavioral proof is in
    /// shell_bridge_notifications.test.mjs (the reload rehydration case); this
    /// pins the WIRING at the source level so a refactor can't silently drop
    /// the persistence and re-open the reload-reset hole.
    #[test]
    fn bridge_js_notification_flood_cap_persisted_across_reload() {
        // The limiter is constructed WITH the persistence store, not the old
        // no-arg makeNotifyRateLimiter().
        assert!(
            SHELL_BRIDGE_JS.contains("makeNotifyRateLimiter(makeNotifyRateStore())"),
            "rate limiter must be constructed with the persistence store (#4849)"
        );
        // The store is keyed off the version-less contract consent key (so the
        // window survives a v1<->v2 reload) with a `:rate` suffix, and is backed
        // by sessionStorage (per-tab, same-origin, reload-surviving).
        assert!(
            SHELL_BRIDGE_JS.contains("ckey + ':rate'"),
            "rate window must use a contract-scoped storage key (#4849)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("sessionStorage.getItem(storeKey)")
                && SHELL_BRIDGE_JS.contains("sessionStorage.setItem(storeKey"),
            "rate window must be persisted in sessionStorage (#4849)"
        );
        // The factory actually rehydrates from and saves to the injected store.
        assert!(
            SHELL_BRIDGE_JS.contains("store.load()")
                && SHELL_BRIDGE_JS.contains("store.save(recent)"),
            "limiter must rehydrate from and persist to the injected store (#4849)"
        );
        // The bfcache reset variant is closed by a `pageshow`-persisted resync
        // (the IIFE does not re-run on back-forward-cache restore, so the
        // in-memory window would otherwise stay stale). Pin the wiring so a
        // refactor can't silently drop it.
        assert!(
            SHELL_BRIDGE_JS.contains("pageshow")
                && SHELL_BRIDGE_JS.contains("notifyLimiter.resync()"),
            "flood-cap window must be resynced from the store on bfcache restore (#4849)"
        );
    }

    /// REFUSE-PLAINTEXT-TOKEN, client side (Codex review, #4513): the durable
    /// per-user token is a high-value bearer secret and must never cross a
    /// plaintext wire. Two INDEPENDENT guards enforce this so a refactor of
    /// either can't reopen the leak:
    ///   1. `SHELL_USER_TOKEN_JS` returns undefined on a non-https page BEFORE
    ///      touching localStorage (never loads/mints/transmits the token), and
    ///   2. the bridge WS-open handler gates the `userToken` append on
    ///      `location.protocol === 'https:'`.
    #[test]
    fn user_token_never_transmitted_over_plaintext() {
        // Guard 1: the https check must precede any localStorage access in the
        // minting IIFE, so an http page returns undefined without reading the
        // stored token.
        let https_guard = SHELL_USER_TOKEN_JS
            .find("location.protocol !== 'https:'")
            .expect("user-token snippet must refuse to run on a non-https page");
        // Anchor on the actual localStorage READ (`localStorage.getItem`), not
        // the bare word "localStorage" which also appears in the rationale
        // comment above the guard.
        let first_storage_access = SHELL_USER_TOKEN_JS
            .find("localStorage.getItem")
            .expect("user-token snippet must read from localStorage");
        assert!(
            https_guard < first_storage_access,
            "the https guard must run BEFORE any localStorage access so an http \
             page never even reads a previously-minted token"
        );
        assert!(
            SHELL_USER_TOKEN_JS.contains("return undefined"),
            "the non-https branch must yield an undefined token"
        );

        // Guard 2: the bridge append is gated on https as a second barrier.
        assert!(
            SHELL_BRIDGE_JS.contains("location.protocol === 'https:'"),
            "bridge must gate the userToken append on a secure connection"
        );
        let https_attach_guard = SHELL_BRIDGE_JS
            .find("userToken && location.protocol === 'https:'")
            .expect("bridge must only attach userToken over https");
        let set_user = SHELL_BRIDGE_JS
            .find("u.searchParams.set('userToken', userToken)")
            .expect("bridge must have a userToken append site");
        assert!(
            https_attach_guard < set_user,
            "the https guard must precede the userToken append"
        );
    }

    /// FAIL CLOSED, not shared-Local (Codex review, #4381): a HOSTED browser
    /// with no per-user token must REFUSE to operate, not silently connect onto
    /// the shared Local delegate-secret namespace. The token is absent for two
    /// reasons that BOTH must fail closed — plaintext http (token withheld by
    /// the transmit guards) and https-but-storage/crypto-failure (mint throws,
    /// catch returns undefined). The unified `hostedMode === true && !userToken`
    /// condition covers both, so the test keys off the token-absent condition
    /// rather than re-checking the protocol. The shell must (a) not load the
    /// app, showing a message instead, and (b) refuse all WebSocket opens.
    #[tokio::test]
    async fn hosted_shell_fails_closed_when_no_user_token() {
        // The hosted shell page must pass the hosted flag to the bridge so it
        // CAN fail closed; without the third `true` arg the bridge can't tell
        // it's hosted.
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
        )
        .await;
        assert!(
            html.contains(&format!(
                "freenetBridge(\"{}\", __freenet_user_token, true);",
                token.as_str()
            )),
            "hosted shell must pass the hosted flag (true) to the bridge; got: {html}"
        );

        // Unified guard: hosted AND no token (for ANY reason). Keying off
        // `!userToken` covers both the http (token withheld) and the
        // https-but-storage-failure (mint returned undefined) cases with one
        // condition. Requires hostedMode === true so non-hosted (hostedMode
        // undefined) is always inert, and a truthy token (hosted+https+minted)
        // operates normally.
        assert!(
            SHELL_BRIDGE_JS.contains("hostedMode === true && !userToken"),
            "fail-closed must require hosted mode AND an absent token (any cause)"
        );
        // The guard must NOT re-check the protocol — that would miss the
        // https+storage-failure case (token undefined despite https).
        assert!(
            !SHELL_BRIDGE_JS.contains("hostedMode === true && location.protocol"),
            "fail-closed must not key off the protocol (misses https+no-storage)"
        );

        // Effect 1 — the app is not loaded: the iframe is removed and a message
        // is shown instead. Anchor on the removeChild of the iframe and the
        // alert role.
        assert!(
            SHELL_BRIDGE_JS.contains("removeChild(iframe)"),
            "fail-closed must not load the app iframe"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("role', 'alert'") || SHELL_BRIDGE_JS.contains("'alert'"),
            "fail-closed must render a visible alert message"
        );

        // Effect 2 — the WS-open handler refuses while hostedNoToken, BEFORE it
        // would otherwise open a socket on the shared Local namespace. Assert the
        // refusal check precedes the real WebSocket construction.
        let refuse = SHELL_BRIDGE_JS
            .find("if (hostedNoToken)")
            .expect("WS-open handler must refuse while hosted+no-token");
        let open_socket = SHELL_BRIDGE_JS
            .find("new WebSocket(u.toString()")
            .expect("bridge must have a WebSocket open site");
        assert!(
            refuse < open_socket,
            "the hostedNoToken refusal must precede opening the real socket"
        );
    }

    /// Regression test for #4645: the hosted fail-closed page must give the
    /// user an ACTIONABLE recovery path, not a dead end.
    ///
    /// The dominant real-world trigger is opening a Freenet app link as a NEW
    /// TAB/WINDOW from inside the sandboxed app iframe (the browser's "open
    /// link in new tab", a middle-click, a right-click menu, `window.open`, or
    /// a `target=_blank` link). Such a context inherits the iframe sandbox, so
    /// it has an opaque origin (`window.origin === 'null'`), so `localStorage`
    /// throws and the per-user token can't be read — and the shell fails
    /// closed. The pre-#4645 page only said "reconnect using https / enable
    /// storage", which is useless for that case: the tab already IS https with
    /// storage; the opaque origin is what blocks it. The page must instead
    /// detect the opaque-origin case and tell the user to re-open the address
    /// in a normal tab, surfacing the URL for one-click copy.
    #[test]
    fn fail_closed_page_gives_actionable_recovery_4645() {
        // Detects the opaque-origin (sandboxed new-tab) case. The tell-tale is
        // `window.origin` serializing to the string "null" for an opaque
        // origin (confirmed empirically against try.freenet.org).
        assert!(
            SHELL_BRIDGE_JS.contains("window.origin === 'null'"),
            "fail-closed page must detect the opaque-origin (sandboxed new-tab) \
             case so it can give the right recovery guidance (#4645)"
        );
        // The "open in a normal tab" recovery only helps on a SECURE connection:
        // over http even a fresh tab can't mint a token (SHELL_USER_TOKEN_JS
        // refuses), so the https guidance must win when a page is BOTH sandboxed
        // and plaintext. Pin that the reopen affordance is gated on
        // `opaqueOrigin && !plaintext` rather than opaqueOrigin alone (Codex P3).
        assert!(
            SHELL_BRIDGE_JS.contains("opaqueOrigin && !plaintext"),
            "the re-open recovery must be gated on a secure connection, so an \
             http+sandboxed page is told to use https rather than to re-open a \
             URL that still can't mint a token"
        );
        // For that case it surfaces the current URL so the user can re-open it
        // in a normal top-level tab (where a real origin lets the token mint).
        assert!(
            SHELL_BRIDGE_JS.contains("field.value = location.href"),
            "fail-closed page must surface the page URL for the user to re-open"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("Copy address"),
            "fail-closed page must offer a one-click copy of the address"
        );
        // The recovery copy must be explicit that THIS tab is the one stuck and
        // that reloading / retyping the URL here will keep failing — the exact
        // confusion a user reported (the address bar shows the clean URL, so a
        // reload looks like it should work but stays sandboxed). Steer them to a
        // genuinely new top-level tab.
        assert!(
            SHELL_BRIDGE_JS.contains("brand-new"),
            "recovery copy must tell the user to open a brand-new tab (a reload \
             of this sandbox-inherited tab keeps failing) (#4645)"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("Reloading or editing the address in this tab will"),
            "recovery copy must warn that reloading/editing the address in this \
             same tab will not work (#4645)"
        );
        // The three distinct causes (opaque-origin restricted tab, plain http,
        // storage disabled) get distinct headings so the guidance actually
        // matches the situation rather than blaming https/storage for all.
        assert!(
            SHELL_BRIDGE_JS.contains("Open this app in a normal tab")
                && SHELL_BRIDGE_JS.contains("Secure connection required")
                && SHELL_BRIDGE_JS.contains("Browser storage required"),
            "fail-closed page must tailor its heading to each of the three causes"
        );
        // Anti-footgun: the fail-closed block must NOT try to re-open the app
        // via `window.open` — a popup opened from this already-sandboxed context
        // inherits the sandbox and hits the exact same dead end. Recovery is the
        // user opening a fresh top-level tab themselves. (The bridge does call
        // window.open legitimately in the open_url handler far below, so scope
        // the check to the fail-closed rendering block.)
        let block_start = SHELL_BRIDGE_JS
            .find("if (hostedNoToken) {")
            .expect("fail-closed block present");
        // Anchor the block end on CODE from the normal (non-fail-closed) load
        // branch rather than a comment, so a future comment reword can't
        // silently move the boundary. `iframe.getAttribute('data-src')` is the
        // first statement of the else branch and never appears in the
        // fail-closed block.
        let block_end = SHELL_BRIDGE_JS[block_start..]
            .find("iframe.getAttribute('data-src')")
            .expect("fail-closed block is followed by the normal iframe-load branch")
            + block_start;
        let fail_closed_block = &SHELL_BRIDGE_JS[block_start..block_end];
        assert!(
            !fail_closed_block.contains("window.open("),
            "fail-closed recovery must not call window.open (a popup from this \
             sandboxed context inherits the sandbox and re-hits the dead end)"
        );
    }

    /// Regression test for #4645 (second half): the hosted Account popover must
    /// offer a "New ID" control so a user can start over with a fresh identity
    /// from the UI, instead of hand-deleting the token from browser devtools —
    /// the exact friction the try.freenet.org feedback reported. Minting is
    /// delegated to SHELL_USER_TOKEN_JS: clearing the stored key is enough
    /// because the next load mints a new random token when the key is absent.
    #[test]
    fn hosted_bar_offers_new_id_control_4645() {
        // The button exists in the Account popover.
        assert!(
            HOSTED_BAR_HTML.contains("id=\"fnnewid\""),
            "hosted bar must expose a New ID control (#4645)"
        );
        // The handler is wired to that button.
        let handler = HOSTED_BAR_JS
            .find("getElementById('fnnewid')")
            .expect("New ID button must have a click handler");
        // It clears the SAME storage key SHELL_USER_TOKEN_JS mints under, so the
        // reload re-mints a fresh token. Pin the exact key on BOTH sides: a
        // rename on either would silently turn "New ID" into a no-op (clears a
        // key nobody reads) or a broken reset (clears the wrong key).
        assert!(
            HOSTED_BAR_JS.contains("removeItem('__freenet_user_token__')"),
            "New ID must clear the stored per-user token"
        );
        assert!(
            SHELL_USER_TOKEN_JS.contains("__freenet_user_token__"),
            "New ID clears the key SHELL_USER_TOKEN_JS mints under; keep in sync"
        );
        // Destructive action: confirm BEFORE clearing, so a cancelled prompt
        // leaves the current identity intact.
        let confirm = HOSTED_BAR_JS[handler..]
            .find("window.confirm(")
            .map(|o| o + handler)
            .expect("New ID must confirm before discarding the current identity");
        let clear = HOSTED_BAR_JS[handler..]
            .find("removeItem('__freenet_user_token__')")
            .map(|o| o + handler)
            .expect("New ID must clear the token");
        assert!(
            confirm < clear,
            "the confirm prompt must run before the token is cleared, so \
             cancelling keeps the current identity"
        );
        // The reload (which re-mints) comes after clearing.
        let reload = HOSTED_BAR_JS[clear..]
            .find("location.reload()")
            .map(|o| o + clear)
            .expect("New ID must reload so a fresh token mints");
        assert!(clear < reload, "must clear the token before reloading");
    }

    /// The hosted "Move to my peer" migration (#4592) must default to a
    /// ONE-CLICK open against the user's local peer, keeping copy-the-URL only
    /// as a SECONDARY fallback. Before this, the only affordance was a link the
    /// user had to hand-copy and paste into another browser — friction for the
    /// exact action we want them to take. This pins the whole primary/secondary
    /// contract so a refactor can't silently regress it back to copy-only.
    #[test]
    fn hosted_bar_migration_defaults_to_one_click_open_4592() {
        // (a) A PRIMARY "open on my peer" control that opens the peer import
        // page in a new browsing context (so the hosted tab is left intact).
        assert!(
            HOSTED_BAR_HTML.contains("id=\"fnmigrateopen\""),
            "hosted bar must expose a primary 'open on my peer' control (#4592)"
        );
        let open_idx = HOSTED_BAR_HTML
            .find("id=\"fnmigrateopen\"")
            .expect("primary open control present");
        // The control is an anchor with target=_blank in the SAME element, so a
        // plain click (or cmd/middle-click into another profile) opens the peer
        // import page directly — the "direct link" the friction complaint asked
        // for. Assert the target belongs to this control (nearby), not anywhere.
        let target_idx = HOSTED_BAR_HTML
            .find("target=\"_blank\"")
            .expect("the primary open control must target a new browsing context");
        assert!(
            target_idx > open_idx && target_idx - open_idx < 120,
            "target=_blank must be on the primary open control's own element"
        );

        // (b) The mint handler performs the one-click open: it opens a tab and
        // navigates it to the freshly minted LOCAL peer import link, instead of
        // only revealing a box to copy. Both the open and the loopback import
        // path must be present in the migrate handler.
        let mint = HOSTED_BAR_JS
            .find("getElementById('fnmigrate')")
            .expect("Move-to-my-peer button must have a click handler");
        assert!(
            HOSTED_BAR_JS[mint..].contains("window.open("),
            "the migration default must open the peer import page directly \
             (one-click), not merely surface a link to copy"
        );
        // Reverse-tabnabbing hardening: the freshly-opened tab must have its
        // window.opener severed so the destination peer page can't navigate the
        // hosted tab back to a spoofed origin. Set synchronously while the tab is
        // still about:blank; it survives the later peerWin.location navigation.
        assert!(
            HOSTED_BAR_JS[mint..].contains("peerWin.opener = null"),
            "the one-click open must sever window.opener to prevent \
             reverse tabnabbing"
        );
        assert!(
            HOSTED_BAR_JS.contains("/hosted/import?source="),
            "the one-click open must target the local peer's import page"
        );
        // The handler sets the primary control's href too, so the fallback link
        // (used when the pop-up is blocked) points at the same minted link.
        assert!(
            HOSTED_BAR_JS.contains("migrateOpen.href = link"),
            "the primary open control's href must be set to the minted link"
        );

        // (c) Copy-the-URL remains available as the SECONDARY option (kept for a
        // peer on a different computer/browser/profile) — never removed.
        assert!(
            HOSTED_BAR_HTML.contains("id=\"fnmigratecopy\"")
                && HOSTED_BAR_HTML.contains("id=\"fnmigratelink\""),
            "copy-the-URL must remain available as a secondary fallback"
        );
        assert!(
            HOSTED_BAR_JS.contains("getElementById('fnmigratecopy')"),
            "the secondary copy-link control must stay wired to clipboard copy"
        );
    }

    /// Non-hosted mode must NEVER reach the fail-closed path: the bridge is
    /// called with one argument, so `hostedMode` is undefined and the whole
    /// hostedNoToken branch is inert — the app loads and connects over http
    /// exactly as before #4381. (Single-user nodes commonly run over http.)
    #[tokio::test]
    async fn non_hosted_shell_never_fails_closed() {
        let token = AuthToken::generate();
        let html = response_body(
            shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
        )
        .await;
        // The 1-arg call leaves hostedMode undefined; `=== true` is then false.
        assert!(
            html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
            "non-hosted shell must use the 1-arg freenetBridge call; got: {html}"
        );
        assert!(
            !html.contains(", true);"),
            "non-hosted shell must not pass the hosted-mode flag to the bridge"
        );
    }

    /// Isolation-boundary regression (Codex review, #4513): the sandboxed app
    /// must never be able to choose its own per-user (or auth) identity by
    /// putting a `userToken` / `authToken` on the WebSocket URL it asks the
    /// shell to open. The bridge must STRIP any caller-supplied credentials
    /// before injecting its own, and the strip must run BEFORE the conditional
    /// `set('userToken', ...)` — otherwise a caller token survives whenever the
    /// shell's minted token is undefined (localStorage disabled / private mode),
    /// letting the app pick its own secret namespace.
    #[test]
    fn bridge_js_strips_caller_supplied_user_token_before_injecting() {
        let delete_user = SHELL_BRIDGE_JS
            .find("u.searchParams.delete('userToken')")
            .expect("bridge must delete any caller-supplied userToken");
        let delete_auth = SHELL_BRIDGE_JS
            .find("u.searchParams.delete('authToken')")
            .expect("bridge must delete any caller-supplied authToken (defense-in-depth)");
        let set_auth = SHELL_BRIDGE_JS
            .find("u.searchParams.set('authToken', authToken)")
            .expect("bridge must inject the shell's authToken");
        // Anchor on the userToken append itself rather than the full
        // conditional, whose guard expression is allowed to evolve (it now also
        // carries the https barrier — see user_token_never_transmitted_over_plaintext).
        let conditional_set_user = SHELL_BRIDGE_JS
            .find("u.searchParams.set('userToken', userToken)")
            .expect("bridge must conditionally inject the shell's minted userToken");

        // The deletes must precede BOTH injection points, so a caller value can
        // never survive — including the undefined-token path where the
        // conditional set is skipped entirely.
        assert!(
            delete_user < conditional_set_user,
            "delete('userToken') must run before the conditional set so a caller \
             token cannot survive when the shell's token is undefined"
        );
        assert!(
            delete_user < set_auth && delete_auth < set_auth,
            "credential deletes must run before the authToken injection"
        );
    }

    #[test]
    fn bridge_js_contains_origin_check() {
        assert!(
            SHELL_BRIDGE_JS.contains("LOCAL_API_ORIGIN"),
            "bridge JS must validate WebSocket origin"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("u.protocol !== 'ws:'"),
            "bridge JS must explicitly check WebSocket protocol"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("MAX_CONNECTIONS"),
            "bridge JS must limit concurrent connections"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("connections.delete(msg.id)"),
            "bridge JS must clean up connections"
        );
        // Shell message handler must validate types and restrict favicon schemes
        assert!(
            SHELL_BRIDGE_JS.contains("typeof msg.title === 'string'"),
            "bridge JS must type-check title before setting"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("typeof msg.href === 'string'"),
            "bridge JS must type-check favicon href before setting"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("scheme !== 'https' && scheme !== 'data'"),
            "bridge JS must restrict favicon href to https/data schemes"
        );
        // Hash forwarding: iframe→shell must validate # prefix and truncate
        assert!(
            SHELL_BRIDGE_JS.contains("msg.type === 'hash'"),
            "bridge JS must handle hash shell messages"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("h.charAt(0) === '#'"),
            "bridge JS must require # prefix on hash values"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("location.hash.slice(0, 8192)"),
            "bridge JS must truncate hash to 8192 chars"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("history.replaceState"),
            "bridge JS must use replaceState for hash updates to avoid polluting browser history"
        );
        // Initial hash: built into iframe src from data-src for deep linking
        assert!(
            SHELL_BRIDGE_JS.contains("iframe.getAttribute('data-src')"),
            "bridge JS must read base URL from data-src attribute"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("iframe.src = iframeSrc"),
            "bridge JS must set iframe src from data-src (single load, no race)"
        );
        assert!(
            !SHELL_BRIDGE_JS.contains("iframe.addEventListener('load'"),
            "bridge JS must NOT use load event (race with WASM init; hash is in iframe URL via data-src)"
        );
        assert!(
            !SHELL_BRIDGE_JS.contains("slice(0, 1024)"),
            "hash limit must be 8192, not 1024"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("popstate"),
            "bridge JS must forward hash on browser back/forward"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("hashchange"),
            "bridge JS must forward hash on manual URL fragment edits"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("if (location.hash)"),
            "bridge JS must not forward empty hash to iframe"
        );
        // Clipboard proxy: shell writes to clipboard on behalf of sandboxed iframe
        assert!(
            SHELL_BRIDGE_JS.contains("msg.type === 'clipboard'"),
            "bridge JS must handle clipboard shell messages"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("navigator.clipboard.writeText"),
            "bridge JS must proxy clipboard writes through the shell"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("msg.text.slice(0, 2048)"),
            "bridge JS must truncate clipboard text to 2048 chars"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("lastClipboard"),
            "bridge JS must rate-limit clipboard writes"
        );
        assert!(
            !SHELL_BRIDGE_JS.contains("clipboard.readText")
                && !SHELL_BRIDGE_JS.contains("clipboard.read("),
            "bridge JS must be clipboard write-only — no read access"
        );
    }

    #[test]
    fn shim_js_validates_message_source() {
        assert!(
            WEBSOCKET_SHIM_JS.contains("event.source !== window.parent"),
            "shim JS must validate message source"
        );
    }

    // Regression guard for the OOPIF zero-copy send() fix. wasm-bindgen hands
    // send() a Uint8Array, which is NOT `instanceof ArrayBuffer`; the pre-fix
    // code therefore left the postMessage transfer list empty and every
    // outbound WS frame was structured-clone COPIED across the process
    // boundary (a ~2.7 s main-thread CPU burst on tab-focus flush). The fix
    // transfers the backing buffer for ArrayBuffer *views* too, copying exactly
    // the view window off `data.buffer` first (works for TypedArrays AND a
    // DataView, which has no `.slice()`) so it never detaches WASM linear
    // memory. The behavioural coverage is in
    // tests/playwright/tests/websocket-shim.spec.ts (a real browser asserting
    // the actual transfer list); this content guard runs in the default CI job
    // and fails fast if the JS is reverted.
    #[test]
    fn shim_js_transfers_array_buffer_views_zero_copy() {
        // The old, buggy one-liner must be gone.
        assert!(
            !WEBSOCKET_SHIM_JS.contains("data instanceof ArrayBuffer ? [data] : []"),
            "shim send() must not use the copy-everything transfer check (OOPIF copy regression)"
        );
        // Views (Uint8Array / DataView) must be recognised and their buffer
        // transferred.
        assert!(
            WEBSOCKET_SHIM_JS.contains("ArrayBuffer.isView(data)"),
            "shim send() must transfer ArrayBuffer views zero-copy"
        );
        // The view window must be copied off data.buffer (NOT data.slice(),
        // which a DataView lacks) before transfer, so WASM linear memory is
        // never detached.
        assert!(
            WEBSOCKET_SHIM_JS.contains("data.buffer.slice("),
            "shim send() must copy the view window off data.buffer (handles DataView too)"
        );
        assert!(
            !WEBSOCKET_SHIM_JS.contains("data.slice()"),
            "shim send() must not call data.slice() (a DataView has no .slice())"
        );
        assert!(
            WEBSOCKET_SHIM_JS.contains("transfer = [buf]"),
            "shim send() must transfer the freshly copied buffer, not the shared/WASM one"
        );
    }

    #[test]
    fn get_path_v1() {
        let req_path = "/v1/contract/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html";
        let base_dir = PathBuf::from(
            "/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/",
        );
        let uri: axum::http::Uri = req_path.parse().unwrap();
        let parsed = get_file_path(uri).unwrap();
        let result = base_dir.join(parsed);
        assert_eq!(
            PathBuf::from(
                "/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html"
            ),
            result
        );
    }

    #[test]
    fn get_path_v2() {
        let req_path = "/v2/contract/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html";
        let base_dir = PathBuf::from(
            "/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/",
        );
        let uri: axum::http::Uri = req_path.parse().unwrap();
        let parsed = get_file_path(uri).unwrap();
        let result = base_dir.join(parsed);
        assert_eq!(
            PathBuf::from(
                "/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html"
            ),
            result
        );
    }

    #[test]
    fn get_path_v2_web() {
        let req_path =
            "/v2/contract/web/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/assets/app.js";
        let uri: axum::http::Uri = req_path.parse().unwrap();
        let parsed = get_file_path(uri).unwrap();
        assert_eq!(parsed, "assets/app.js");
    }

    #[test]
    fn get_file_path_rejects_unknown_version() {
        let req_path = "/v3/contract/web/somekey/assets/app.js";
        let uri: axum::http::Uri = req_path.parse().unwrap();
        let result = get_file_path(uri);
        assert!(result.is_err(), "expected error for /v3/ prefix");
    }

    #[test]
    fn bridge_js_contains_navigate_handler() {
        // The shell bridge must handle 'navigate' messages for multi-page
        // website navigation within the sandboxed iframe (issue #3833).
        assert!(
            SHELL_BRIDGE_JS.contains("msg.type === 'navigate'"),
            "bridge JS must handle navigate shell messages"
        );
        // Navigate handler must validate that target paths live inside the
        // contract namespace. The shape check is the security boundary —
        // it rejects /v1/node/..., /v1/delegate/..., and other gateway
        // endpoints as navigation targets.
        assert!(
            SHELL_BRIDGE_JS.contains("CONTRACT_PREFIX_RE"),
            "navigate handler must reference the contract-shape regex"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("cleanPath.match(CONTRACT_PREFIX_RE)"),
            "navigate handler must enforce contract-shape check on target path"
        );
        // Same-contract branch: must update iframe.src in place, not do a
        // top-level navigation (preserves auth token and client state).
        assert!(
            SHELL_BRIDGE_JS.contains("newContractPrefix === contractPrefix"),
            "same-contract branch must compare prefixes"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("resolved.searchParams.set('__sandbox', '1')"),
            "same-contract branch must add __sandbox=1 to navigated URL"
        );
        // Cross-contract branch: must do a top-level window.location.assign
        // so the gateway's contract_home regenerates a fresh shell + auth
        // token. Reusing the iframe with a different contract would leak
        // the old auth token and misattribute server-side requests
        // (Codex review P1).
        assert!(
            SHELL_BRIDGE_JS.contains("window.location.assign"),
            "cross-contract branch must use top-level navigation so the gateway \
             regenerates a fresh shell + auth token for the new contract"
        );
        // Cross-contract branch must preserve the query string so any
        // app-level routing arguments on the link survive the hop. Dropping
        // `resolved.search` previously stripped query parameters that the
        // destination webapp depended on.
        assert!(
            SHELL_BRIDGE_JS
                .contains("window.location.assign(cleanPath + resolved.search + cappedHash)"),
            "cross-contract branch must preserve the query string via resolved.search"
        );
        // Navigate handler must validate same-origin
        assert!(
            SHELL_BRIDGE_JS.contains("resolved.origin !== location.origin"),
            "navigate handler must reject cross-origin navigation"
        );
        // Sandbox attributes themselves must not be widened — the fix is
        // scoped to the shell-side postMessage handler only.
        assert!(
            !SHELL_BRIDGE_JS.contains("allow-top-navigation"),
            "sandbox attributes must not be widened as part of the cross-contract nav fix"
        );
    }

    /// Decision returned by `navigate_shell_check` mirroring the JS handler.
    #[derive(Debug, PartialEq, Eq)]
    enum NavDecision {
        /// Same-contract hop: update iframe.src in place (keeps the shell).
        SameContract { new_prefix: String },
        /// Cross-contract hop: top-level window.location.assign reloads the
        /// shell with a fresh auth token via contract_home.
        CrossContract { new_prefix: String },
        /// Rejected — reason is only for test diagnostics.
        Reject(&'static str),
    }

    /// Pure-Rust mirror of the JS `navigate` postMessage handler's decision
    /// logic. Uses the `url` crate so WHATWG normalization (`..`, percent
    /// encoding, relative hrefs, protocol-relative URLs) matches what a
    /// browser would do inside `new URL(href, iframe.src)`.
    ///
    /// Returns the decision: accept as same-contract / accept as
    /// cross-contract / reject. Kept in sync with SHELL_BRIDGE_JS — any
    /// change to the JS regex or origin check must update both.
    fn navigate_shell_check(iframe_src: &str, current_prefix: &str, href: &str) -> NavDecision {
        use url::Url;

        if href.len() > 4096 {
            return NavDecision::Reject("href > 4096 bytes");
        }
        let base = match Url::parse(iframe_src) {
            Ok(u) => u,
            Err(_) => return NavDecision::Reject("iframe_src unparseable"),
        };
        let resolved = match base.join(href) {
            Ok(u) => u,
            Err(_) => return NavDecision::Reject("href unparseable"),
        };
        if resolved.origin() != base.origin() {
            return NavDecision::Reject("cross-origin");
        }
        let clean_path = resolved.path();
        let re = regex::Regex::new(r"^(/v[12]/contract/web/[^/]+/)").unwrap();
        let caps = match re.captures(clean_path) {
            Some(c) => c,
            None => return NavDecision::Reject("shape check failed"),
        };
        let new_prefix = caps.get(1).unwrap().as_str().to_string();
        if new_prefix == current_prefix {
            NavDecision::SameContract { new_prefix }
        } else {
            NavDecision::CrossContract { new_prefix }
        }
    }

    const IFRAME_SRC: &str = "http://127.0.0.1:50509/v1/contract/web/AAAA/?__sandbox=1";
    const CURRENT: &str = "/v1/contract/web/AAAA/";

    #[test]
    fn navigate_same_contract_subpage() {
        // Subpage inside the currently-loaded contract → same-contract hop.
        // The shell must NOT do a top-level navigation; it updates iframe.src
        // in place.
        let d = navigate_shell_check(
            IFRAME_SRC,
            CURRENT,
            "http://127.0.0.1:50509/v1/contract/web/AAAA/page2",
        );
        assert_eq!(
            d,
            NavDecision::SameContract {
                new_prefix: "/v1/contract/web/AAAA/".to_string()
            }
        );
    }

    #[test]
    fn navigate_cross_contract_hop() {
        // PRIMARY REGRESSION TEST for the Delta cross-contract-link report.
        // A link to a different contract must be ACCEPTED as a cross-contract
        // hop, which the shell handles via window.location.assign so the
        // gateway can regenerate a fresh auth token via contract_home.
        let d = navigate_shell_check(
            IFRAME_SRC,
            CURRENT,
            "http://127.0.0.1:50509/v1/contract/web/BBBB/welcome",
        );
        assert_eq!(
            d,
            NavDecision::CrossContract {
                new_prefix: "/v1/contract/web/BBBB/".to_string()
            }
        );
    }

    #[test]
    fn navigate_cross_contract_v2_api() {
        assert!(matches!(
            navigate_shell_check(
                IFRAME_SRC,
                CURRENT,
                "http://127.0.0.1:50509/v2/contract/web/CCCC/app"
            ),
            NavDecision::CrossContract { .. }
        ));
    }

    #[test]
    fn navigate_relative_same_contract() {
        // Relative href (most common real-world case for client-side
        // routing): `page2` resolves against iframe src → same-contract.
        assert!(matches!(
            navigate_shell_check(IFRAME_SRC, CURRENT, "page2"),
            NavDecision::SameContract { .. }
        ));
    }

    #[test]
    fn navigate_rejects_gateway_internal_path() {
        // The shape check is the security boundary. Navigation must not
        // become a ladder into non-contract gateway endpoints, including
        // via paths whose literal string matches contract shape but whose
        // WHATWG-normalized form escapes the namespace.
        for evil in [
            "http://127.0.0.1:50509/v1/node/status",
            "http://127.0.0.1:50509/v1/delegate/foo",
            "http://127.0.0.1:50509/api/secret",
            "http://127.0.0.1:50509/",
            "http://127.0.0.1:50509/v1/contract/AAAA/",
            "http://127.0.0.1:50509/v3/contract/web/AAAA/",
        ] {
            assert!(
                matches!(
                    navigate_shell_check(IFRAME_SRC, CURRENT, evil),
                    NavDecision::Reject(_)
                ),
                "non-contract path must be rejected: {evil}"
            );
        }
    }

    #[test]
    fn navigate_rejects_path_traversal() {
        // Path-traversal via `..` would break out of the contract namespace
        // post-normalization. `url::Url` resolves `..` the same way
        // browsers do via `new URL()`.
        for evil in [
            "http://127.0.0.1:50509/v1/contract/web/AAAA/../../node/status",
            "http://127.0.0.1:50509/v1/contract/web/AAAA/../../v1/node/status",
            // Relative variant resolved against IFRAME_SRC.
            "../../node/status",
        ] {
            let d = navigate_shell_check(IFRAME_SRC, CURRENT, evil);
            assert!(
                matches!(d, NavDecision::Reject(_)),
                "traversal must be rejected post-normalization: {evil} -> {d:?}"
            );
        }
    }

    #[test]
    fn navigate_rejects_cross_origin() {
        for evil in [
            "http://evil.example.com/v1/contract/web/AAAA/",
            "https://127.0.0.1:50509/v1/contract/web/AAAA/",
            // Protocol-relative resolves against IFRAME_SRC's scheme but
            // different host → cross-origin.
            "//evil.example.com/v1/contract/web/AAAA/",
        ] {
            assert!(
                matches!(
                    navigate_shell_check(IFRAME_SRC, CURRENT, evil),
                    NavDecision::Reject("cross-origin")
                ),
                "cross-origin must be rejected: {evil}"
            );
        }
    }

    #[test]
    fn navigate_rejects_non_http_schemes() {
        for evil in [
            "javascript:alert(1)",
            "data:text/html,<script>",
            "file:///etc/passwd",
        ] {
            let d = navigate_shell_check(IFRAME_SRC, CURRENT, evil);
            assert!(
                matches!(d, NavDecision::Reject(_)),
                "non-http scheme must be rejected: {evil} -> {d:?}"
            );
        }
    }

    #[test]
    fn navigate_rejects_oversized_href() {
        let huge = format!(
            "http://127.0.0.1:50509/v1/contract/web/AAAA/{}",
            "a".repeat(5000)
        );
        assert!(matches!(
            navigate_shell_check(IFRAME_SRC, CURRENT, &huge),
            NavDecision::Reject("href > 4096 bytes")
        ));
    }

    #[test]
    fn navigate_rejects_empty_contract_key_segment() {
        // `//foo` would leave the key segment empty; regex `[^/]+` rejects.
        assert!(matches!(
            navigate_shell_check(
                IFRAME_SRC,
                CURRENT,
                "http://127.0.0.1:50509/v1/contract/web//foo"
            ),
            NavDecision::Reject(_)
        ));
    }

    #[test]
    fn navigate_rejects_missing_trailing_slash() {
        // `/v1/contract/web/AAAA` without a trailing slash doesn't match the
        // shape regex. Pin this so a future regex tweak can't silently
        // loosen it.
        assert!(matches!(
            navigate_shell_check(
                IFRAME_SRC,
                CURRENT,
                "http://127.0.0.1:50509/v1/contract/web/AAAA"
            ),
            NavDecision::Reject(_)
        ));
    }

    #[test]
    fn navigation_interceptor_js_intercepts_clicks() {
        // The navigation interceptor must catch <a> clicks and route them
        // through postMessage for multi-page navigation (issue #3833).
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("document.addEventListener('click'"),
            "interceptor must listen for click events"
        );
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("type: 'navigate'"),
            "interceptor must send navigate messages to shell"
        );
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("__freenet_shell__: true"),
            "interceptor must use __freenet_shell__ namespace"
        );
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("e.preventDefault()"),
            "interceptor must prevent default link behavior"
        );
        // Cross-origin links should use open_url instead of navigate
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("type: 'open_url'"),
            "interceptor must route cross-origin links through open_url"
        );
        // Same-origin links: must respect explicit non-_self target so
        // webapps that open multiple tabs within their own contract still
        // work.
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("target.target"),
            "interceptor must respect target attribute on same-origin links"
        );
        // Must walk up DOM to handle clicks on child elements of <a>
        assert!(
            NAVIGATION_INTERCEPTOR_JS.contains("target.parentElement"),
            "interceptor must walk up DOM to find <a> ancestor"
        );
    }

    /// Regression test for freenet/river#208.
    ///
    /// River (and any other webapp) transforms links to include
    /// `target="_blank"`. The original interceptor short-circuited on any
    /// anchor with an explicit target, so cross-origin clicks fell through
    /// to the browser. Without `allow-popups-to-escape-sandbox`, that
    /// produced a sandboxed popup with a null origin, which broke CORS on
    /// every external site (GitHub issues page reported by @lukors).
    ///
    /// Pin the contract: the cross-origin branch MUST be reached before
    /// the target-attribute check, i.e. the origin classification dominates.
    #[test]
    fn navigation_interceptor_handles_cross_origin_target_blank() {
        let js = NAVIGATION_INTERCEPTOR_JS;

        // Anchor the cross-origin check and the target-attribute check and
        // confirm the cross-origin check comes FIRST in the source order.
        let cross_origin_idx = js
            .find("target.origin !== location.origin")
            .expect("cross-origin check present");
        let target_attr_idx = js
            .find("target.target && target.target !== '_self'")
            .expect("target-attribute check present");
        assert!(
            cross_origin_idx < target_attr_idx,
            "cross-origin classification must run before the target-attribute \
             skip, otherwise target=\"_blank\" cross-origin links bypass the \
             open_url bridge (freenet/river#208). cross_origin_idx={cross_origin_idx}, \
             target_attr_idx={target_attr_idx}"
        );

        // The cross-origin branch must call preventDefault and send open_url,
        // not navigate.
        let cross_origin_block = &js[cross_origin_idx..target_attr_idx];
        assert!(
            cross_origin_block.contains("preventDefault"),
            "cross-origin branch must preventDefault before opening popup"
        );
        assert!(
            cross_origin_block.contains("type: 'open_url'"),
            "cross-origin branch must send open_url, not navigate"
        );
    }

    /// Regression test for freenet/freenet-core#3853.
    ///
    /// After #3852 fixed freenet/river#208, the cross-origin click handler
    /// unconditionally `preventDefault`ed and sent `open_url`. Middle-click,
    /// ctrl-click, shift-click and meta-click all collapsed to a single
    /// foreground tab because the interceptor dropped modifier state and
    /// the shell handler called `window.open` with no flags.
    ///
    /// A second latent bug: the listener was `click` only, but middle-click
    /// fires `auxclick` (not `click`), so middle-clicks on cross-origin
    /// links fell through to the browser's default handling and produced
    /// the same null-origin sandboxed popup #3852 was meant to prevent.
    ///
    /// We can only meaningfully preserve shift-click (via a popup window
    /// feature) because browsers refuse to honour background-tab placement
    /// when `window.open` is called outside a direct user gesture. Pin the
    /// minimal contract at both ends:
    ///   1. The interceptor registers BOTH `click` and `auxclick` so
    ///      middle-click is actually intercepted.
    ///   2. The interceptor's cross-origin branch forwards `shiftKey` in
    ///      the posted message, sourced from the MouseEvent.
    ///   3. The shell bridge's `open_url` handler reads `msg.shiftKey` and
    ///      uses the `popup` window feature when it's true.
    #[test]
    fn navigation_interceptor_forwards_shift_key_for_open_url() {
        let js = NAVIGATION_INTERCEPTOR_JS;

        let cross_origin_idx = js
            .find("type: 'open_url'")
            .expect("interceptor open_url branch present");
        let target_attr_idx = js
            .find("target.target && target.target !== '_self'")
            .expect("same-origin target check present");
        let block = &js[cross_origin_idx..target_attr_idx];

        assert!(
            block.contains("shiftKey"),
            "cross-origin open_url postMessage must include shiftKey to honour \
             shift-click as a new-window request (#3853); got block: {block}"
        );
        // Must be sourced from the actual event, not a hardcoded constant.
        assert!(
            block.contains("e.shiftKey"),
            "interceptor must forward `e.shiftKey` from the MouseEvent, not a literal (#3853)"
        );
    }

    /// Regression test for the middle-click half of #3853. Middle-click is
    /// dispatched as `auxclick` in modern browsers, NOT `click`, so the
    /// interceptor must listen on both events. Without the auxclick
    /// listener, middle-clicks on cross-origin `<a target="_blank">` links
    /// bypass the `open_url` routing and fall through to the browser's
    /// default handling, producing a null-origin sandboxed popup (exactly
    /// what #3852 was meant to prevent).
    #[test]
    fn navigation_interceptor_listens_on_click_and_auxclick() {
        let js = NAVIGATION_INTERCEPTOR_JS;
        assert!(
            js.contains("addEventListener('click'"),
            "interceptor must register a click listener"
        );
        assert!(
            js.contains("addEventListener('auxclick'"),
            "interceptor must register an auxclick listener so middle-click \
             on cross-origin links is also routed through open_url (#3853)"
        );
    }

    /// Regression test for freenet/freenet-core#4645.
    ///
    /// Anchor clicks are intercepted, but an app that calls `window.open()`
    /// from its own JS bypasses the click/auxclick listeners. In a sandboxed
    /// iframe (opaque origin, no `allow-popups-to-escape-sandbox`) that popup
    /// inherits the sandbox, gets a null origin, cannot read the per-user
    /// access key, and dead-ends on the "Open this app in a normal tab"
    /// per-user-isolation page — the exact symptom users hit when a hosted
    /// app opens a new tab. The interceptor must therefore override
    /// `window.open` and route http(s) targets through the shell's `open_url`
    /// bridge (real origin), returning null.
    ///
    /// Pin the contract so a future edit can't silently drop the override or
    /// regress the edge cases the review surfaced. Behavioral coverage lives in
    /// `crates/core/tests/playwright/tests/window-open.spec.ts` (runs in CI via
    /// playwright-shell.yml); these source pins are the cheap CI-required guard.
    ///   1. `window.open` is reassigned (the override exists).
    ///   2. The override forwards through the SAME `open_url` bridge as the
    ///      cross-origin anchor path, posting the RESOLVED ABSOLUTE url
    ///      (`resolved.href`) — not the raw arg, which would drop relative opens.
    ///   3. Targets are resolved against `document.baseURI` so the shell gets an
    ///      absolute URL.
    ///   4. Only http/https is forwarded; other schemes fall back to native.
    ///   5. `_self`/`_parent`/`_top` (in-place navigation) fall back to native.
    ///   6. Loopback targets fall back to native (open_url refuses them).
    ///   7. The arg is coerced to a string so URL objects are forwarded.
    #[test]
    fn navigation_interceptor_overrides_window_open() {
        let js = NAVIGATION_INTERCEPTOR_JS;
        assert!(
            js.contains("window.open = function"),
            "interceptor must override window.open so programmatic opens don't \
             create a sandbox-inherited null-origin popup (#4645)"
        );
        // The override is the final construct in the IIFE, so slicing to EOF
        // scopes assertions to it (nothing but the `})();` close follows).
        let open_fn_idx = js
            .find("window.open = function")
            .expect("window.open override present");
        let override_block = &js[open_fn_idx..];
        assert!(
            override_block.contains("type: 'open_url'"),
            "window.open override must forward through the open_url bridge (#4645)"
        );
        assert!(
            override_block.contains("__freenet_shell__: true"),
            "window.open override must use the __freenet_shell__ namespace (#4645)"
        );
        // Must post the RESOLVED absolute URL, not the raw (possibly relative)
        // arg — posting `url` raw would make open_url's `new URL(msg.url)` throw
        // on a relative target and silently drop the open.
        assert!(
            override_block.contains("url: resolved.href"),
            "window.open override must post the resolved absolute URL \
             (resolved.href), not the raw arg (#4645)"
        );
        // Resolve against the iframe base so relative targets become absolute.
        assert!(
            override_block.contains("document.baseURI"),
            "window.open override must resolve targets against document.baseURI \
             so the shell gets an absolute URL (#4645)"
        );
        // http(s)-only forward; everything else falls back to native open.
        assert!(
            override_block.contains("resolved.protocol !== 'http:'")
                && override_block.contains("resolved.protocol !== 'https:'"),
            "window.open override must only forward http(s); other schemes \
             fall back to native (#4645)"
        );
        // In-place navigation targets are not new-window requests -> native.
        // Names are normalized (case-insensitive) before the reserved check.
        assert!(
            override_block.contains("targetName === '_self'")
                && override_block.contains("String(name).toLowerCase()"),
            "window.open override must leave _self/_parent/_top (case-insensitive) \
             to native so in-place navigation isn't turned into a new tab (#4645)"
        );
        // Loopback targets must fall back to native: open_url refuses them, so
        // forwarding would silently drop the open on local nodes.
        assert!(
            override_block.contains("isLoopbackHost(resolved.hostname)"),
            "window.open override must fall back to native for loopback hosts \
             (open_url refuses them) so local-node opens aren't silently dropped (#4645)"
        );
        // Coerce the arg so window.open(new URL(...)) is forwarded, not sent to
        // native (which would recreate the dead end).
        assert!(
            override_block.contains("String(url)"),
            "window.open override must string-coerce the arg so URL objects are \
             forwarded rather than dead-ended (#4645)"
        );
        // Only the shell's DIRECT child forwards: a deeper descendant's parent
        // is an app frame the shell never hears, so it must stay native.
        assert!(
            override_block.contains("window.parent !== window.top"),
            "window.open override must only intercept the shell's direct child \
             (window.parent === window.top), else nested-frame opens are lost (#4645)"
        );
        // Non-forwarded cases delegate to the captured native window.open.
        assert!(
            override_block.contains("fallbackOpen"),
            "window.open override must fall back to native open for the \
             non-forwarded cases (#4645)"
        );
        // The forwarded case drops the WindowProxy (matches the shell's
        // noopener open) and asks for a plain tab (shiftKey false).
        assert!(
            override_block.contains("shiftKey: false"),
            "window.open override must request a plain tab (shiftKey false) (#4645)"
        );
        assert!(
            override_block.contains("return null;"),
            "window.open override must return null for the forwarded case (#4645)"
        );
    }

    /// Regression test for freenet/freenet-core#3853 shell-side.
    ///
    /// The shell `open_url` handler must read `msg.shiftKey` and, when true,
    /// call `window.open` with the `popup` window feature so Firefox honours
    /// the shift-click-opens-new-window intent. Other browsers may fall back
    /// to a tab, which is acceptable.
    #[test]
    fn shell_open_url_handler_honours_shift_key() {
        let js = SHELL_BRIDGE_JS;

        // Locate the open_url branch and bound the slice to the next
        // `else if` branch so assertions can't match unrelated JS.
        let open_url_idx = js
            .find("msg.type === 'open_url'")
            .expect("shell open_url branch present");
        let rest = &js[open_url_idx..];
        let next_branch = rest[1..]
            .find("} else if")
            .map(|i| i + 1)
            .unwrap_or(rest.len());
        let block = &rest[..next_branch];

        assert!(
            block.contains("msg.shiftKey"),
            "open_url handler must read msg.shiftKey for new-window intent (#3853)"
        );
        // The popup window feature is the concrete mechanism; pin it so a
        // future refactor that reads shiftKey but forgets the feature is
        // caught.
        assert!(
            block.contains("'noopener,noreferrer,popup'"),
            "open_url handler must pass the `popup` window feature on shift-click \
             so Firefox honours the new-window intent (#3853); got block: {block}"
        );
        // The non-shift path must still use the plain new-tab features so
        // left-click behaviour is unchanged.
        assert!(
            block.contains("'noopener,noreferrer'"),
            "open_url handler must keep the plain new-tab path for non-shift clicks"
        );
    }

    /// Regression test for freenet/river#231.
    ///
    /// The shell `open_url` handler must accept `http:` URLs in addition to
    /// `https:`. The original https-only check silently dropped clicks on
    /// markdown links to plain-HTTP services (the trigger was the Network
    /// Telemetry dashboard linked from the Freenet River channel header,
    /// plain HTTP at the time and since moved to
    /// `https://telemetry.freenet.org/`) — the user clicked the link and
    /// nothing happened, no console output, no popup, no error. The
    /// localhost block stays so a pasted `http://127.0.0.1:NNNN/` link
    /// can't be used to target services running on the reader's machine.
    #[test]
    fn shell_open_url_handler_accepts_http_and_https_but_blocks_localhost() {
        let js = SHELL_BRIDGE_JS;
        let open_url_idx = js
            .find("msg.type === 'open_url'")
            .expect("shell open_url branch present");
        let rest = &js[open_url_idx..];
        let next_branch = rest[1..]
            .find("} else if")
            .map(|i| i + 1)
            .unwrap_or(rest.len());
        let block = &rest[..next_branch];

        // Both schemes accepted. The check must reject ONLY non-http(s),
        // not just non-https.
        assert!(
            block.contains("u.protocol !== 'https:'") && block.contains("u.protocol !== 'http:'"),
            "open_url handler must accept both http: and https: schemes \
             (freenet/river#231); got block: {block}"
        );
        // The check must NOT be a bare https-only filter that drops http: URLs
        // before they reach the localhost block. Pin the precise structure so
        // a future "tighten security" refactor that re-introduces the
        // https-only filter trips this test.
        assert!(
            !block.contains("if (u.protocol !== 'https:') return;"),
            "open_url handler must NOT reject http: URLs outright; the bug \
             this test pins (freenet/river#231) was that an https-only filter \
             silently dropped clicks on http: links the user pasted. Got: {block}"
        );
        // Localhost block must still be present — http: + localhost is the
        // CSRF/private-network surface the original check was guarding against.
        assert!(
            block.contains("'localhost'") && block.contains("'127.0.0.1'"),
            "open_url handler must continue to block localhost/loopback hosts \
             so http: scheme acceptance doesn't open a CSRF surface against \
             services on the reader's machine; got block: {block}"
        );
    }

    /// WHATWG `URL.hostname` serializes an IPv6 literal WITH brackets, so
    /// `new URL('http://[::1]/').hostname === '[::1]'`. The handler must
    /// therefore STRIP the brackets before comparing against `::1`, or the
    /// loopback refusal never matches and a forged link to the viewer's IPv6
    /// loopback slips through. (An earlier version of this test and the code
    /// comment both had the fact inverted — asserting hostname is bracket-LESS —
    /// so the test passed while the IPv6 loopback was in fact unblocked. #4645.)
    #[test]
    fn shell_open_url_handler_blocks_ipv6_loopback() {
        let js = SHELL_BRIDGE_JS;
        let open_url_idx = js
            .find("msg.type === 'open_url'")
            .expect("shell open_url branch present");
        let rest = &js[open_url_idx..];
        let next_branch = rest[1..]
            .find("} else if")
            .map(|i| i + 1)
            .unwrap_or(rest.len());
        let block = &rest[..next_branch];

        // The handler must strip surrounding brackets from the hostname before
        // the loopback comparison, so the serialized `[::1]` matches `::1`.
        assert!(
            block.contains(r"replace(/^\[/"),
            "open_url handler must strip the leading bracket from an IPv6 \
             hostname before comparing, else `[::1]` never matches `::1` and \
             IPv6 loopback is unblocked (#4645); got block: {block}"
        );
        assert!(
            block.contains("'::1'"),
            "open_url handler must compare the bracket-stripped hostname \
             against `::1`; got block: {block}"
        );
    }

    /// Direct postMessages from a malicious iframe can synthesize an
    /// `open_url` payload without going through the upstream
    /// `NAVIGATION_INTERCEPTOR_JS` scheme filter, so the shell-side
    /// `new URL().protocol` allow-list is the primary gate against
    /// `javascript:` / `data:` / `file:` / `blob:` / `chrome:`. This
    /// test pins the explicit allow-list shape so a refactor that
    /// drops the explicit comparison (e.g. switches to a regex or a
    /// blocklist) is forced to handle these schemes consciously.
    #[test]
    fn shell_open_url_handler_rejects_dangerous_schemes() {
        let js = SHELL_BRIDGE_JS;
        let open_url_idx = js
            .find("msg.type === 'open_url'")
            .expect("shell open_url branch present");
        let rest = &js[open_url_idx..];
        let next_branch = rest[1..]
            .find("} else if")
            .map(|i| i + 1)
            .unwrap_or(rest.len());
        let block = &rest[..next_branch];

        // The check must be an explicit allow-list of `http:` and `https:`.
        // `new URL('javascript:alert(1)').protocol === 'javascript:'`,
        // and `'javascript:' !== 'http:' && 'javascript:' !== 'https:'`,
        // so the explicit allow-list rejects it. Same for data:, blob:,
        // file:, chrome:, chrome-extension:, vbscript:.
        assert!(
            block.contains("u.protocol !== 'https:'")
                && block.contains("u.protocol !== 'http:'")
                && block.contains("&&"),
            "open_url handler must use an explicit `http:` AND `https:` \
             allow-list (joined with &&) so dangerous schemes \
             (javascript:, data:, file:, blob:, chrome:, vbscript:) \
             are rejected by the shell-side check, which is the \
             primary scheme gate (a malicious iframe can postMessage \
             open_url without going through the upstream interceptor); \
             got block: {block}"
        );
    }

    #[tokio::test]
    async fn sandbox_content_serves_sub_pages() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        // Create a sub-page
        let sub_html = r#"<!DOCTYPE html><html><head></head><body><h1>News</h1></body></html>"#;
        std::fs::write(dir.path().join("news.html"), sub_html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "news.html")
                .await
                .unwrap(),
        )
        .await;

        // Sub-page content must be served
        assert!(
            result.contains("<h1>News</h1>"),
            "sub-page content not served"
        );
        // WebSocket shim must be injected
        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected in sub-page"
        );
        // Navigation interceptor must be injected
        assert!(
            result.contains("type: 'navigate'"),
            "navigation interceptor not injected in sub-page"
        );
    }

    #[tokio::test]
    async fn sandbox_content_serves_directory_index() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        // Create a subdirectory with index.html
        std::fs::create_dir(dir.path().join("news")).unwrap();
        let sub_html =
            r#"<!DOCTYPE html><html><head></head><body><h1>News Index</h1></body></html>"#;
        std::fs::write(dir.path().join("news/index.html"), sub_html).unwrap();

        let result = response_body(
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "news")
                .await
                .unwrap(),
        )
        .await;

        assert!(
            result.contains("<h1>News Index</h1>"),
            "directory index.html not served"
        );
        assert!(
            result.contains("FreenetWebSocket"),
            "WebSocket shim not injected in directory index"
        );
    }

    #[tokio::test]
    async fn sandbox_content_rejects_path_traversal() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        std::fs::write(dir.path().join("index.html"), "<html></html>").unwrap();

        // Attempting to traverse above the contract directory must fail
        let result =
            sandbox_content_body(dir.path(), key, ApiVersion::V1, "../../../etc/passwd").await;
        assert!(result.is_err(), "path traversal should be rejected");
    }

    #[tokio::test]
    async fn sandbox_content_rejects_absolute_path() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        std::fs::write(dir.path().join("index.html"), "<html></html>").unwrap();

        // Absolute paths would make Path::join replace the base directory entirely,
        // so they must be rejected by the component check.
        let result = sandbox_content_body(dir.path(), key, ApiVersion::V1, "/etc/passwd").await;
        assert!(result.is_err(), "absolute path should be rejected");
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn sandbox_content_rejects_symlink_escape() {
        let dir = tempfile::tempdir().unwrap();
        let key = "testkey123";
        let outside = tempfile::tempdir().unwrap();
        std::fs::write(outside.path().join("secret.html"), "<html>secret</html>").unwrap();

        // Create a symlink inside the contract directory pointing outside it.
        // The canonicalize + starts_with check must catch this even though the
        // component-level ParentDir check would not.
        std::os::unix::fs::symlink(
            outside.path().join("secret.html"),
            dir.path().join("escape.html"),
        )
        .unwrap();

        let result = sandbox_content_body(dir.path(), key, ApiVersion::V1, "escape.html").await;
        assert!(result.is_err(), "symlink escape should be rejected");
    }

    #[test]
    fn bridge_js_navigate_pushes_history_state() {
        // Regression test for #3839: in-contract navigation must push a browser
        // history entry so back/forward works and the address bar updates.
        assert!(
            SHELL_BRIDGE_JS.contains("history.pushState"),
            "navigate handler must push a history entry"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("__freenet_nav__: true"),
            "history state must be tagged with __freenet_nav__ so popstate can recognise it"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("iframePath: newIframePath"),
            "history state must carry the iframe sandbox URL for popstate restore"
        );
        // The pushState URL must be the clean path (without __sandbox=1) so the
        // address bar shows the user-visible subpage URL, not the sandbox flag.
        assert!(
            SHELL_BRIDGE_JS.contains("cleanPath + cappedHash"),
            "pushState URL must be the clean (non-sandbox) path"
        );
    }

    #[test]
    fn bridge_js_popstate_restores_iframe_from_state() {
        // Regression test for #3839: browser back/forward must restore the
        // iframe to the previously-visited subpage by reading history state.
        assert!(
            SHELL_BRIDGE_JS.contains("addEventListener('popstate'"),
            "bridge JS must listen for popstate events"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("state.__freenet_nav__ === true"),
            "popstate handler must check for the __freenet_nav__ marker"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("state.iframePath.indexOf(contractPrefix) === 0"),
            "popstate handler must validate the restored iframe path stays under the contract prefix"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("iframe.src = state.iframePath"),
            "popstate handler must restore iframe.src from state"
        );
    }

    #[test]
    fn bridge_js_seeds_initial_history_state() {
        // Regression test for #3839: the initial history entry must carry the
        // __freenet_nav__ marker so that navigating back to the first page
        // still restores the iframe via popstate.
        assert!(
            SHELL_BRIDGE_JS.contains("history.replaceState"),
            "bridge JS must seed history state on load"
        );
        // The replaceState call for hash forwarding must preserve existing
        // state (history.state) rather than passing null, or it would wipe the
        // __freenet_nav__ marker and break back-navigation.
        assert!(
            SHELL_BRIDGE_JS.contains("history.replaceState(history.state"),
            "hash replaceState must preserve the existing state object"
        );
    }

    #[test]
    fn bridge_js_navigate_caps_href_length() {
        // Prevent a malicious contract from bloating history.state / URL by
        // spamming arbitrarily large navigate hrefs.
        assert!(
            SHELL_BRIDGE_JS.contains("msg.href.length > 4096"),
            "navigate handler must cap msg.href length"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("resolved.hash.slice(0, 8192)"),
            "navigate handler must cap the hash component stored in history.state"
        );
    }

    #[test]
    fn bridge_js_hash_update_syncs_nav_state() {
        // When the iframe sends a hash update while sitting on a pushState
        // entry, the stored iframePath must be refreshed to include the new
        // fragment — otherwise back/forward loses the user's fragment.
        assert!(
            SHELL_BRIDGE_JS.contains("curState.__freenet_nav__ === true"),
            "hash handler must detect tagged nav state"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("basePath + h"),
            "hash handler must rewrite iframePath with the new fragment"
        );
    }

    #[test]
    fn bridge_js_popstate_skips_reload_when_iframe_on_target() {
        // bfcache restore can fire popstate while the iframe is already on
        // the target path. Re-assigning iframe.src would tear down live
        // WebSockets for no reason.
        assert!(
            SHELL_BRIDGE_JS.contains("iframe.src.indexOf(state.iframePath) === -1"),
            "popstate handler must skip reload when iframe is already on the target"
        );
    }

    #[test]
    fn bridge_js_cleans_up_websockets_on_navigate() {
        // When navigating to a new page, existing WebSocket connections must be
        // closed to prevent resource leaks from orphaned connections.
        assert!(
            SHELL_BRIDGE_JS.contains("connections.forEach"),
            "navigate handler must close existing WebSocket connections"
        );
        assert!(
            SHELL_BRIDGE_JS.contains("connections.clear()"),
            "navigate handler must clear the connections map"
        );
    }
}