fandhe-ai 0.13.0

Facade crate for a from-scratch Rust AI/ML library: tensors, autodiff, and CPU/CUDA/Metal backends behind one composition root
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
//! ローカルモデルレジストリ公開面(イシュー #2087・親 #2082)。
//!
//! 利用者がホームディレクトリ配下に手動配置したプリトレイン重みを、
//! 名前・バージョンで一元管理し同期ロードする入口 `ModelRegistry` を
//! 提供する。数値経路(`Op`/`BackendOps`/VJP/カーネル)には一切
//! 触れず、ホスト側のパス管理と既存の
//! [`crate::interop::safetensors::load_safetensors_f32`](#2019)への
//! 委譲のみで完結する(REQ-12「任意 `BackendOps` 注入の公開 API を
//! 設けない」と矛盾しない)。
//!
//! # ディレクトリレイアウト(規定)
//!
//! ```text
//! <cache_dir>/                      既定 $HOME/.fandhe-ai/models
//!                                    (Windows のキャッシュルート解決は
//!                                    $USERPROFILE。ただし `load`/
//!                                    `available_models` は Windows で
//!                                    fail-closed 非対応。
//!                                    [`ModelRegistry::new`] 参照)
//!   <name>/                         `[A-Za-z0-9._-]+`(先頭 '.' 不可)
//!     <version>/                    同上。semver 等の記法は解釈しない
//!                                    不透明な文字列として扱う
//!       model.safetensors           F32 テンソルのみ・キー = state_dict
//!                                    キー(転置・リネームなし。REQ-7)
//!       download.json               `download` が置く取得メタデータ(`url`・
//!                                    `etag`・`content_length`・`sha256`・
//!                                    `fetched_at`。#3126)。条件付き GET と
//!                                    `304` 再検証の根拠で、破損・部分改変の
//!                                    検出用にすぎず真正性の根拠ではない。
//!                                    `load`/`available_models` は参照しない
//! ```
//!
//! 配置は利用者(または将来のダウンロード側。#2088 リモート取得。本
//! イシューのスコープ外)が行うか、[`crate::model::ModelRegistry::save`](#3119。
//! `docs/facade-model-registry-decision.md` §16)で書き込む。`save` 以外の
//! メソッド(`new`・`load`・`available_models` 等)はディレクトリの
//! 作成・削除を一切行わない読み取り専用で、`save` も削除は自分の一時
//! ファイルに限り、作成するのはルートと `<name>`・`<version>` だけである。
//!
//! # `load` の戻り値型(受入条件からの逸脱・承認事項)
//!
//! イシュー受入条件の字面は `load` の戻り値要素型を単一 `Tensor` とする
//! が、本実装は [`std::collections::HashMap<String, crate::Tensor<f32>>`]
//! (state dict)を返す。safetensors ファイルは複数テンソルを保持し、
//! 消費先の [`crate::compat::Sequential::load_state_dict`] も map を
//! 取るため、単一 `Tensor` 化には恣意的な規則(どのキーを選ぶか)が
//! 必要になる。詳細・代替案(`load_tensor(name, version, key)` の追加)
//! は `docs/facade-model-registry-decision.md` を参照。
//!
//! # 非信頼入力の扱い(OWASP A01/A03。`.claude/rules/security.md`)
//!
//! `name`・`version` は利用者から渡される非信頼入力であり、パス
//! トラバーサル(`..`・絶対パス区切り混入等)を防ぐため
//! `validate_component` が許可文字集合(`[A-Za-z0-9._-]+`・先頭 `.`
//! 不可)でファイルシステムへ触れる前に fail-closed 検証する。
//! safetensors ファイル自体の検証(ヘッダ・dtype・shape)は
//! `crate::interop::safetensors` に一元化されており本モジュールは
//! 複製・迂回しない。
//!
//! 文字集合検証だけでは、レジストリ配下に事前配置されたシンボリック
//! リンク経由のキャッシュルート脱出(`<root>/<name>`・`<version>`・
//! `model.safetensors` のいずれかがリンクである場合)を防げない
//! (codex-review 指摘・PR #2226)。`load`・`available_models` は
//! ともに内部の `resolve_model_file` を経由し、次の多層防御で
//! シンボリックリンク経由の脱出・検査後の差し替え(TOCTOU)・非通常
//! ファイル(FIFO・ソケット・デバイス等)の受理・上限なしのメモリ
//! 確保(手順 5〜6)をすべて防ぐ(同じく
//! codex-review 指摘・PR #2226。当初案は「検査後の open までの TOCTOU
//! は当時の許容依存区分に無い `libc` 直接依存が要るため対象外」としていた
//! が、`libc` クレートを追加せずとも `std::os::unix::fs::OpenOptionsExt`
//! (`custom_flags`)で `O_NOFOLLOW`/`O_NONBLOCK` の生値を渡せるため、
//! この判断は撤回し下記の対策へ差し替えた):
//!
//! 1. `name`・`version`・葉ファイルの各段を [`std::fs::symlink_metadata`]
//!    (リンクを辿らない)で検査し、シンボリックリンクを拒否する。葉は
//!    `is_dir() == false` ではなく **`is_file() == true`** を明示要求し、
//!    FIFO・Unix ソケット・デバイスファイル等を拒否する。
//! 2. 葉パスを canonicalize し正規化済みキャッシュルート配下である
//!    ことを多層防御として再確認する(この canonicalize 自体は検査時点
//!    のスナップショットであり単独では TOCTOU を閉じない)。
//! 3. 葉を `open_leaf_no_follow` で開く。Linux(x86_64/aarch64 限定。
//!    `O_NOFOLLOW` の生値はアーキ間で異なるため。`crate::fs_guard` の `open_flags`
//!    モジュール doc 参照)/macOS は `O_NOFOLLOW`(最終コンポーネントの
//!    シンボリックリンク追跡をカーネルレベルで拒否)と `O_NONBLOCK`
//!    (FIFO への差し替えで `open` が無期限ブロックするのを防ぐ。通常
//!    ファイルには無効)を生の flag 値で付与する。それ以外の OS・
//!    アーキテクチャ(Windows・上記以外の Linux アーキ等)は同等の
//!    no-follow 実装を持たない、または値の出典を確認していないため、
//!    安全性を仮定せず **fail-closed でオープン自体を拒否**する
//!    (`ErrorKind::Unsupported` を返し `ModelError::Io` へ
//!    丸める)。キャッシュディレクトリ解決(`USERPROFILE`)自体は
//!    Windows でも動作するが、`resolve_model_file` を経由する `load`
//!    は常に失敗し(`ModelError::Io`)、`available_models` は各
//!    バージョンの存在確認に `resolve_model_file(..).is_ok()` を使う
//!    ため、失敗を伝播せず対象バージョンを一覧から除外する(結果的に
//!    Windows では常に空の一覧を返す。エラーにはならない)。
//! 4. 開いたハンドルの `fstat`([`std::fs::File::metadata`])で
//!    `is_file()` を再確認したうえで、Unix では手順 1 で取得した
//!    `symlink_metadata` の `(dev, ino)` と一致することを検証する
//!    (「検査と open のハンドル一体化」)。手順 1〜3 の間に
//!    `name`/`version`/葉のいずれかを別ファイルへ差し替えられても、
//!    差し替え後に実際に開かれたファイルの実体識別子(デバイス番号+
//!    inode 番号)は検査時点のものと一致しないため、この不一致で
//!    確実に検出できる(パスの再解決ではなく実体の同一性で判定するため、
//!    中間ディレクトリの差し替えも同じ仕組みで捕捉する)。
//! 5. 開いたハンドルの `fstat` で取得済みのファイルサイズ
//!    (`std::fs::Metadata::len`)を `MAX_MODEL_FILE_BYTES` と比較し、
//!    上回る場合は読み取りに入る前に `ModelError::TooLarge` で拒否する
//!    (`model.safetensors` は非信頼な外部フォーマット入力であり、
//!    サイズ検証なしに丸ごと `Vec` へ確保するとメモリ枯渇を招く。
//!    codex-review 指摘・PR #2226・P0。AGENTS.md「外部フォーマットの
//!    パース検証(P0)」長さ事前検証要件。当初 8 GiB としていたが、
//!    一般的な実行環境(GitHub ホステッド runner の既定 7 GiB RAM 等)
//!    では単一ファイルの 8 GiB 確保自体が実質的な OOM 防止にならない
//!    という codex-review 再指摘を受け 1 GiB へ引き下げた。値の
//!    導出根拠は `MAX_MODEL_FILE_BYTES` のドキュメントを参照。
//!    `crate::fs_guard` の crate 内限定 const のため intra-doc link にはせずコードスパンで
//!    参照する〈rustdoc は非公開項目のリンク先を解決できず
//!    `--no-deps` ビルドで `broken_intra_doc_links` になるため〉)。
//! 6. 以降は同じ [`std::fs::File`] ハンドルからバイト列を読み取る
//!    (パスを使って再度 open し直すと手順 3〜4 で閉じた TOCTOU 窓が
//!    復活するため、ハンドルの使い回しは必須)。手順 5 の fstat 後に
//!    ファイルが差し替え・追記されて増大する TOCTOU にも備え、
//!    `load` 側は `std::io::Read::take` で `MAX_MODEL_FILE_BYTES + 1`
//!    バイトを上限に読み取り、実際に読めたバイト数が
//!    `MAX_MODEL_FILE_BYTES` を超えていれば同じく
//!    `ModelError::TooLarge` で拒否する(許容量ちょうどで打ち切る
//!    と超過を検出できないため `+ 1` バイト分だけ多く読む)。読み
//!    取ったバイト列は
//!    [`load_safetensors_f32_from_bytes`](crate::interop::safetensors::load_safetensors_f32_from_bytes)
//!    へ渡す。
//!
//! 対象外として残る経路: レジストリ内に事前配置されたハードリンク
//! (攻撃者が任意タイミングで作成できるのは同一ファイルシステム上の
//! 既存ファイルへのリンクのみであり、所有者・権限チェックを伴わない
//! 本レジストリの脅威モデル外)。
//!
//! # Windows 対応状況
//!
//! 公開ドキュメントとの整合(codex-review 指摘・PR #2226・P1)。
//!
//! [`crate::model::ModelRegistry::new`] のキャッシュルート解決
//! (`$USERPROFILE` 優先)自体は Windows でも動作するが、
//! [`crate::model::ModelRegistry::load`]・
//! [`crate::model::ModelRegistry::available_models`] が経由する `resolve_model_file`
//! の葉オープン(`open_leaf_no_follow`)は Linux/macOS 限定実装のため、
//! Windows では常に失敗する(`load` は必ず `Err`、`available_models`
//! は常に空の一覧を返す。上記「非信頼入力の扱い」節・
//! `open_leaf_no_follow` のドキュメント参照)。安全な Windows 実装
//! (`file_index`/
//! `volume_serial_number` によるハンドル識別子照合を含む)の追加は
//! スコープ外として別イシューで追跡する(詳細は
//! `docs/facade-model-registry-decision.md`)。
//!
//! facade 自体は x86_64-pc-windows-msvc でビルドでき、CI の Windows クロス
//! clippy で継続検査している(#2390・#2391)。ただし `ModelRegistry::load`/
//! `available_models` は上記のとおり `Unsupported` の fail-closed のままで、
//! Windows 実機での動作は未検証(#2393)。
//!
//! # 対象外
//!
//! HF hub 固有の解決(汎用 HTTPS 取得は [`crate::model::ModelRegistry::download`]。#3125)、
//! `docs/model-distribution-design.md` の作成(親 #2082 の成果物)、version 記法の解釈
//! (semver・hash 等)・最新版解決、`compat::Sequential` への直結
//! ラッパー(`Sequential::from_registry` 等)、非 F32 dtype、改竄検知、
//! Windows 向け安全な no-follow オープン実装。

use std::collections::HashMap;
use std::ffi::OsString;
use std::fmt;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};

use crate::Tensor;
use crate::fs_guard::{MAX_MODEL_FILE_BYTES, open_leaf_no_follow};
use crate::interop::safetensors::{LoadError, SaveError, load_safetensors_f32_from_bytes};

// `download`(#3125)の private 実装モジュール。公開面は本ファイルの型・メソッドに閉じる
// (api_surface が `pub mod`/`pub use` の不在と `ureq`/`libc` トークンの不在を検査する)。
#[cfg(unix)]
mod cache;
#[cfg(unix)]
mod dirfd;
#[cfg(unix)]
mod download;
#[cfg(all(test, unix))]
mod loopback_tests;
#[cfg(unix)]
mod sha256;
#[cfg(all(test, unix))]
mod test_http_server;
#[cfg(unix)]
mod transport;
#[cfg(unix)]
mod url;

/// レイアウト規定上のファイル名(モジュール doc 参照)。
const MODEL_FILE_NAME: &str = "model.safetensors";

/// ローカルモデルレジストリの失敗を表す型付きエラー。`#[non_exhaustive]`:
/// 将来のフィールド追加(例: 改竄検知失敗)を非破壊にするため。
#[non_exhaustive]
#[derive(Debug)]
pub enum ModelError {
    /// 既定のキャッシュディレクトリを解決できない([`ModelRegistry::new`]。
    /// Unix は `HOME`、Windows は `USERPROFILE` のいずれも未設定)。
    CacheDirUnavailable,
    /// `name`・`version` のいずれかが許可文字集合
    /// (`[A-Za-z0-9._-]+`・先頭 `.` 不可)に適合しない
    /// (パストラバーサル対策。ファイルシステムへ触れる前に拒否する)。
    InvalidComponent { kind: &'static str, value: String },
    /// `<cache_dir>/<name>/<version>/model.safetensors` が通常ファイル
    /// として存在しない。
    NotFound { name: String, version: String },
    /// `model.safetensors` のファイルサイズが固定上限(内部定数
    /// `MAX_MODEL_FILE_BYTES`)を超過している(メモリ枯渇対策。
    /// モジュール doc「非信頼入力の扱い」節手順 5〜6 参照)。`len` は
    /// 検出時点で判明していた実際のバイト数(`fstat` 実測値、または
    /// TOCTOU 検出時は `max + 1` 超)、`max` はその固定上限値。
    TooLarge {
        name: String,
        version: String,
        len: u64,
        max: u64,
    },
    /// 保存対象のテンソル集合が空([`ModelRegistry::save`]。空の
    /// safetensors は `load` で何も復元できないため書き込み前に拒否する。
    /// `name`・`version` は許可文字集合検証済みの値で、パスは含まない)。
    EmptyModel { name: String, version: String },
    /// safetensors エンコード失敗([`ModelRegistry::save`]。
    /// `crate::interop::safetensors::SaveError` を連鎖)。
    Save(SaveError),
    /// safetensors デコード失敗(`crate::interop::safetensors::LoadError`
    /// を連鎖。ヘッダ不整合・未対応 dtype・shape 不整合等)。
    Load(LoadError),
    /// ダウンロード URL が不正([`ModelRegistry::download`])。`https` 以外・資格情報付き・
    /// ホスト/ポート不正・制御文字・長さ超過・解決不能なリダイレクト先など。入力 URL は
    /// クエリ・資格情報を含みうるため、値は持たず静的な理由だけを返す(A09)。
    InvalidUrl { reason: &'static str },
    /// 期待 SHA-256([`Sha256Pin`])の形式が不正(64 桁の 16 進以外)。
    InvalidPin,
    /// HTTP 応答が 200 以外(リダイレクト以外の 3xx・4xx・5xx・206 を含む)。URL は持たない。
    Http { status: u16 },
    /// リダイレクト回数が上限(`max`)を超えた。
    TooManyRedirects { max: u32 },
    /// 受信バイト数が `Content-Length` と一致しない(切断・過剰送信)。
    ContentLengthMismatch { expected: u64, received: u64 },
    /// 受信内容の SHA-256 が期待値(pin)と一致しない。ハッシュは秘密情報ではない。
    HashMismatch {
        expected: Sha256Pin,
        actual: Sha256Pin,
    },
    /// ネットワーク・TLS 失敗。ureq のエラーは URL を含みうるため `Display` を通さず、静的
    /// メッセージの `io::Error`(種別のみ保持)へ変換している。
    Network(std::io::Error),
    /// 上記以外の I/O 失敗(存在確認時の権限エラー等)。読み込み
    /// バッファの確保失敗([`std::io::ErrorKind::OutOfMemory`]。
    /// `reserve_read_buffer`)はその一例。
    Io(std::io::Error),
    /// `download.json`(取得メタデータ)が不正(破損・未知キー・型違い・不正な etag 等。
    /// #3126)。読み出し側は再取得へフォールバックするため通常は呼び出し側へ届かず、
    /// 書き込もうとしたメタデータが不正な場合に返る。URL・パスは持たない(A09)。
    CacheMetadata { reason: &'static str },
    /// ディスク満杯(`io::ErrorKind::StorageFull`/`QuotaExceeded`。ダウンロード経路のみ。
    /// #3126)。`Display` にパスは含めない。
    StorageFull(std::io::Error),
    /// 権限エラー(`io::ErrorKind::PermissionDenied`。ダウンロード経路のみ。#3126)。
    /// `Display` にパスは含めない。
    PermissionDenied(std::io::Error),
}

impl fmt::Display for ModelError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ModelError::CacheDirUnavailable => {
                write!(
                    f,
                    "既定のモデルキャッシュディレクトリを解決できません(HOME/USERPROFILE 未設定)"
                )
            }
            ModelError::InvalidComponent { kind, value } => {
                write!(f, "不正な {kind}: {value:?}")
            }
            ModelError::NotFound { name, version } => {
                write!(
                    f,
                    "モデルが見つかりません(name={name}・version={version})"
                )
            }
            ModelError::TooLarge {
                name,
                version,
                len,
                max,
            } => write!(
                f,
                "モデルファイルが上限を超えています(name={name}・version={version}・{len} バイト > 上限 {max} バイト)"
            ),
            ModelError::EmptyModel { name, version } => write!(
                f,
                "保存するテンソルが空です(name={name}・version={version})"
            ),
            ModelError::Save(e) => write!(f, "モデルの保存に失敗しました: {e}"),
            ModelError::Load(e) => write!(f, "モデルのロードに失敗しました: {e}"),
            ModelError::InvalidUrl { reason } => write!(f, "不正なダウンロード URL: {reason}"),
            ModelError::InvalidPin => write!(
                f,
                "期待 SHA-256 の形式が不正です(64 桁の 16 進文字列が必要です)"
            ),
            ModelError::Http { status } => {
                write!(f, "HTTP 応答が成功ではありません(ステータス {status})")
            }
            ModelError::TooManyRedirects { max } => {
                write!(f, "リダイレクト回数が上限({max})を超えました")
            }
            ModelError::ContentLengthMismatch { expected, received } => write!(
                f,
                "受信サイズが Content-Length と一致しません(期待 {expected} バイト・受信 {received} バイト)"
            ),
            ModelError::HashMismatch { expected, actual } => {
                write!(
                    f,
                    "SHA-256 が一致しません(期待 {expected}・実際 {actual})"
                )
            }
            ModelError::Network(e) => write!(f, "ネットワーク取得に失敗しました: {e}"),
            ModelError::Io(e) => write!(f, "モデルレジストリの I/O に失敗しました: {e}"),
            ModelError::CacheMetadata { reason } => {
                write!(f, "ダウンロードのキャッシュメタデータが不正です: {reason}")
            }
            ModelError::StorageFull(_) => {
                write!(f, "ディスク容量が不足しているため書き込めません")
            }
            ModelError::PermissionDenied(_) => {
                write!(f, "権限がないため書き込めません")
            }
        }
    }
}

impl std::error::Error for ModelError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ModelError::Save(e) => Some(e),
            ModelError::Load(e) => Some(e),
            ModelError::Io(e) => Some(e),
            ModelError::Network(e) => Some(e),
            ModelError::StorageFull(e) => Some(e),
            ModelError::PermissionDenied(e) => Some(e),
            _ => None,
        }
    }
}

/// 期待する SHA-256(pin)。`download` の完全性検証に渡す([`DownloadOptions::expected_sha256`])。
/// 形式検証は構築で完結する([`Sha256Pin::from_hex`])ため、接続前の拒否が構造的に保証される。
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Sha256Pin([u8; 32]);

impl Sha256Pin {
    /// 64 桁の 16 進文字列(大文字・小文字可)から作る。長さ違い・非 16 進・空白・`0x`
    /// 接頭辞は [`ModelError::InvalidPin`]。
    pub fn from_hex(s: &str) -> Result<Sha256Pin, ModelError> {
        let b = s.as_bytes();
        if b.len() != 64 {
            return Err(ModelError::InvalidPin);
        }
        let nibble = |c: u8| -> Result<u8, ModelError> {
            match c {
                b'0'..=b'9' => Ok(c - b'0'),
                b'a'..=b'f' => Ok(c - b'a' + 10),
                b'A'..=b'F' => Ok(c - b'A' + 10),
                _ => Err(ModelError::InvalidPin),
            }
        };
        let mut out = [0u8; 32];
        for (i, pair) in b.as_chunks::<2>().0.iter().enumerate() {
            out[i] = (nibble(pair[0])? << 4) | nibble(pair[1])?;
        }
        Ok(Sha256Pin(out))
    }
}

impl fmt::Display for Sha256Pin {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for b in self.0 {
            write!(f, "{b:02x}")?;
        }
        Ok(())
    }
}

impl fmt::Debug for Sha256Pin {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Sha256Pin({self})")
    }
}

/// `download` の受信進捗。URL 由来の情報は持たない(A09)。利用者は読めるが構築できない。
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct DownloadProgress {
    /// これまでに受信したバイト数。
    pub received: u64,
    /// `Content-Length`(チャンク転送などで不明なら `None`)。
    pub total: Option<u64>,
}

/// [`ModelRegistry::download_with`] のオプション。タイムアウト・サイズ上限の上書き口は
/// 持たない(固定の安全側既定。`#[non_exhaustive]` なので将来の追加は非破壊)。
#[non_exhaustive]
#[derive(Default)]
pub struct DownloadOptions<'a> {
    expected_sha256: Option<Sha256Pin>,
    progress: Option<&'a mut dyn FnMut(DownloadProgress)>,
}

impl<'a> DownloadOptions<'a> {
    /// 期待 SHA-256 を指定する。不一致なら [`ModelError::HashMismatch`] で何も公開しない。
    pub fn expected_sha256(mut self, pin: Sha256Pin) -> Self {
        self.expected_sha256 = Some(pin);
        self
    }

    /// 受信チャンクごとに呼ばれる進捗コールバックを指定する。
    pub fn progress(mut self, f: &'a mut dyn FnMut(DownloadProgress)) -> Self {
        self.progress = Some(f);
        self
    }
}

impl fmt::Debug for DownloadOptions<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DownloadOptions")
            .field("expected_sha256", &self.expected_sha256)
            .field("progress", &self.progress.as_ref().map(|_| ".."))
            .finish()
    }
}

/// リダイレクト上限(典型的な配布元・CDN は 1〜2 ホップ。ureq 既定 10 より保守的)。
#[cfg(unix)]
const DOWNLOAD_MAX_REDIRECTS: u32 = 5;

/// `name`・`version` の許可文字集合検証(OWASP A03 パストラバーサル
/// 対策)。`load`・[`ModelRegistry::available_models`] の両方が同一の
/// この関数を使う。`kind` はエラーメッセージ用の "name" / "version"。
fn validate_component(kind: &'static str, value: &str) -> Result<(), ModelError> {
    let ok = !value.is_empty()
        && !value.starts_with('.')
        && value
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
    if ok {
        Ok(())
    } else {
        Err(ModelError::InvalidComponent {
            kind,
            value: value.to_string(),
        })
    }
}

/// 本番の一時ファイル名(`compat::model_io::tmp_manifest_name` と同型。先頭 `.` のため
/// `validate_component` を通らず、`load`/`available_models` からは見えない)。
#[cfg(all(
    target_os = "linux",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
fn production_tmp_name() -> String {
    let (pid, nanos, counter) = crate::fs_write::name_entropy();
    format!(".{MODEL_FILE_NAME}.tmp-{pid}-{counter}-{nanos}")
}

/// `/proc/self/fd/<fd>` 形式のパス(保持中のディレクトリ fd を基準にした相対解決用)。
/// `save` はこのパスの下にだけ作成・`rename` する。`<fd>` が指す inode は
/// 開いた時点で固定されるため、以後 `<name>`・`<version>` のパス文字列を
/// シンボリックリンクへ差し替えられても書き込み先は動かない。
#[cfg(all(
    target_os = "linux",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
fn proc_fd_path(dir: &File) -> PathBuf {
    use std::os::unix::io::AsRawFd;
    PathBuf::from(format!("/proc/self/fd/{}", dir.as_raw_fd()))
}

/// `/proc/self/fd` が使えるか(ディレクトリとして開けるか)を確認する。
/// `save` がルートを作る前に呼び、`/proc` 未マウントの環境では副作用(ルート・
/// 親ディレクトリの作成)なしで `Err` を返すための fail-closed 前提検査
/// (設計記録 §16.9。codex-review 指摘・PR #3377 P2)。
#[cfg(all(
    target_os = "linux",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
fn ensure_proc_fd_usable(proc_fd_dir: &Path) -> std::io::Result<()> {
    std::fs::read_dir(proc_fd_dir).map(|_| ())
}

/// `save` のキャッシュルートをディレクトリ fd として開く(ルート自体が
/// シンボリックリンクでも許容する。利用者が選んだ配置で、`load` 側と同じ扱い)。
#[cfg(all(
    target_os = "linux",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
fn open_root_dir(root: &Path) -> std::io::Result<File> {
    use std::os::unix::fs::OpenOptionsExt;
    std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(crate::fs_guard::open_flags::O_DIRECTORY)
        .open(root)
}

/// `parent`(保持中のディレクトリ fd)の直下に `component`(検証済みの 1 段)を
/// ディレクトリとして用意し、その fd を返す。存在しなければ `create_dir` で 1 段だけ
/// 作る(`create_dir_all` は使わない)。開く際は `O_NOFOLLOW|O_DIRECTORY` を付け、
/// 最終コンポーネントがシンボリックリンク・非ディレクトリなら拒否する
/// (`InvalidInput`)。パス検査と使用の間の差し替え窓を作らないよう、検査ではなく
/// 「開いた fd」を以後の基準にする。エラーメッセージにパスを含めない。
/// `/proc` が使えない環境では開けず `Err`(書き込み前の fail-closed)。
#[cfg(all(
    target_os = "linux",
    any(target_arch = "x86_64", target_arch = "aarch64")
))]
fn open_dir_component(parent: &File, component: &str) -> std::io::Result<File> {
    use crate::fs_guard::open_flags::{ELOOP, O_DIRECTORY, O_NOFOLLOW};
    use std::io::{Error as IoError, ErrorKind};
    use std::os::unix::fs::OpenOptionsExt;
    // ENOTDIR(asm-generic errno。x86_64・aarch64 共通)
    const ENOTDIR: i32 = 20;
    let path = proc_fd_path(parent).join(component);
    match std::fs::create_dir(&path) {
        Ok(()) => {}
        Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
        Err(e) => return Err(e),
    }
    std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(O_DIRECTORY | O_NOFOLLOW)
        .open(&path)
        .map_err(|e| match e.raw_os_error() {
            Some(code) if code == ELOOP || code == ENOTDIR => IoError::new(
                ErrorKind::InvalidInput,
                "保存先の中間パスがシンボリックリンクまたはディレクトリではありません",
            ),
            _ => e,
        })
}

/// `len` が `max` を超えていないか検査する純関数(環境非依存の単体
/// テストを可能にするため `resolve_model_file`・[`ModelRegistry::load`]
/// 双方の判定ロジックを切り出した。モジュール doc「非信頼入力の扱い」
/// 節手順 5〜6・`MAX_MODEL_FILE_BYTES`〈`crate::fs_guard` の crate 内限定 const のため
/// intra-doc link にはせずコードスパンで参照〉参照)。
fn enforce_size_limit(name: &str, version: &str, len: u64, max: u64) -> Result<(), ModelError> {
    if len > max {
        Err(ModelError::TooLarge {
            name: name.to_string(),
            version: version.to_string(),
            len,
            max,
        })
    } else {
        Ok(())
    }
}

/// 読み込みバッファ([`ModelRegistry::load_with_limit`] が
/// safetensors ファイル全体を読み込む先)を `capacity_hint` バイトで
/// 事前確保する。本番では `capacity_hint` に
/// `usize::try_from(..).unwrap_or(usize::MAX)` 由来の値(`u64` が
/// `usize` に収まらない場合の飽和)も渡りうる。
///
/// 確保失敗(`Vec::try_reserve` の `Err`)は
/// [`ModelError::Io`]`(`[`std::io::Error::from`]`(`
/// [`std::io::ErrorKind::OutOfMemory`]`))` で返す。
/// `std::io::Error::other` は元エラーを `Box<dyn Error>` として
/// ヒープに確保する(Custom 表現)ため、実メモリ枯渇時にはエラーを
/// 構築すること自体が確保失敗しうる(「確保失敗は panic/abort では
/// なく `Err` で伝播する」契約を報告経路自体が破ってしまう)。対して
/// `io::Error::from(ErrorKind)` は Simple 表現でヒープ確保なしに
/// 構築できるため、確保失敗の報告経路として安全に使える。要求
/// サイズ等の診断情報は失われるが、`ErrorKind::OutOfMemory` だけで
/// 失敗理由は呼び出し元に伝わり、確保失敗の報告で再確保しないことを
/// 優先する。autodiff 側の `nn::init::alloc_failed()`/
/// `eval::linalg::alloc_failed()`(PR #2239)と同じ類型(#2250)。
fn reserve_read_buffer(capacity_hint: usize) -> Result<Vec<u8>, ModelError> {
    let mut bytes = Vec::new();
    bytes
        .try_reserve(capacity_hint)
        .map_err(|_| ModelError::Io(std::io::Error::from(std::io::ErrorKind::OutOfMemory)))?;
    Ok(bytes)
}

/// 公開前検証(設計 §5 方式 (ii)。#3126): 取得済み一時ファイルが妥当な safetensors
/// (ヘッダ長・ヘッダ JSON・オフセット連続性・shape×dtype 長・全体長の一致)であることを
/// 確認する。dtype は限定しない(F16/BF16 の正当なファイルを拒否しない。`load` は従来どおり
/// F32 のみ)。HTML エラーページや破損内容が既存の正常なモデルを置き換えるのを防ぐ(A08)。
/// ファイルサイズは取得時に上限(`MAX_MODEL_FILE_BYTES`)で制限済みで、メモリはファイル全量
/// 1 回分のみ(F32 テンソルの確保は行わない)。
#[cfg(unix)]
fn verify_safetensors_before_commit(file: &File) -> Result<(), ModelError> {
    use std::io::{Seek, SeekFrom};
    let len = file.metadata().map_err(ModelError::Io)?.len();
    let mut reader = file;
    reader.seek(SeekFrom::Start(0)).map_err(ModelError::Io)?;
    let mut bytes = reserve_read_buffer(usize::try_from(len).unwrap_or(usize::MAX))?;
    reader
        .take(MAX_MODEL_FILE_BYTES.saturating_add(1))
        .read_to_end(&mut bytes)
        .map_err(ModelError::Io)?;
    crate::interop::layout::validate_from_bytes(&bytes).map_err(ModelError::Load)?;
    Ok(())
}

/// ダウンロード経路の FS エラーを型付きエラーへ割り当てる(#3126)。`StorageFull`/
/// `QuotaExceeded` は [`ModelError::StorageFull`]、`PermissionDenied` は
/// [`ModelError::PermissionDenied`]、それ以外は従来どおり [`ModelError::Io`]。適用範囲は
/// ダウンロード経路のみで、`save`/`load` の挙動は変えない。
#[cfg(unix)]
fn map_fs_error(e: std::io::Error) -> ModelError {
    use std::io::ErrorKind;
    match e.kind() {
        ErrorKind::StorageFull | ErrorKind::QuotaExceeded => ModelError::StorageFull(e),
        ErrorKind::PermissionDenied => ModelError::PermissionDenied(e),
        _ => ModelError::Io(e),
    }
}

/// 既定キャッシュディレクトリを `home` から純粋に導出する(環境変数の
/// 読み取りは [`ModelRegistry::new`] 側で行い、本関数は環境非依存の
/// 単体テストを可能にするために切り出した private 純関数)。
fn default_cache_dir_from(home: Option<OsString>) -> Option<PathBuf> {
    home.map(|h| PathBuf::from(h).join(".fandhe-ai").join("models"))
}

/// 環境変数から読み取った値が空文字列の場合は「未設定」として扱う
/// (`HOME=""` / `USERPROFILE=""` を有効なホームパスとして受理すると
/// `PathBuf::from("")` はカレントディレクトリ相対の空パスになり、
/// [`default_cache_dir_from`] が `.fandhe-ai/models`
/// というカレントディレクトリ相対のキャッシュルートを構築してしまう
/// ため。本来は [`ModelError::CacheDirUnavailable`] になるべき
/// (Cursor Bugbot 指摘・PR #2226)。
fn non_empty_os_string(value: Option<OsString>) -> Option<OsString> {
    value.filter(|v| !v.is_empty())
}

/// `HOME`/`USERPROFILE` のどちらを優先するかを OS 別に決める純関数
/// (環境変数の読み取り自体は [`ModelRegistry::new`] 側で行い、本関数は
/// 選択ロジックのみを環境非依存の単体テストで検証できるように切り出した。
/// `is_windows` を引数化しているのも同じ理由で、CI が Linux 環境でも
/// Windows 分岐をテストできるようにするため)。
///
/// Windows は `USERPROFILE` を優先する(公開ドキュメント規定の既定
/// ルート `$USERPROFILE/.fandhe-ai/models` と一致させる。codex-review
/// 指摘・PR #2226。`HOME` は両 OS で設定されうるため単純な `HOME`
/// 優先だと Windows で乖離する)。それ以外の OS は `HOME` を優先する。
///
/// 空文字列の値は [`non_empty_os_string`] で「未設定」に正規化した
/// うえで優先順位判定する(Cursor Bugbot 指摘・PR #2226。呼び出し元
/// [`ModelRegistry::new`] 側でフィルタせず本関数側で行うのは、判定
/// ロジックと正規化を分離すると将来どちらかだけが更新され再発しうる
/// ため)。例えば `HOME=""`・`USERPROFILE="/x"` の非 Windows では
/// `HOME` を空文字列のまま優先せず `USERPROFILE` へフォールバックする。
fn select_home_var(
    is_windows: bool,
    home: Option<OsString>,
    userprofile: Option<OsString>,
) -> Option<OsString> {
    let home = non_empty_os_string(home);
    let userprofile = non_empty_os_string(userprofile);
    if is_windows {
        userprofile.or(home)
    } else {
        home.or(userprofile)
    }
}

/// ホームディレクトリ配下のキャッシュディレクトリを基盤とする
/// **ローカル限定**のモデルレジストリ。ディレクトリレイアウトは
/// モジュール doc の規定を参照。読み取り系(`load`・`available_models`)は
/// ディレクトリの作成・削除を行わず、書き込みは [`ModelRegistry::save`] だけが行う。
pub struct ModelRegistry {
    root: PathBuf,
}

impl ModelRegistry {
    /// 既定のキャッシュルート(Unix: `$HOME/.fandhe-ai/models`、
    /// Windows: `$USERPROFILE/.fandhe-ai/models`)で
    /// [`ModelRegistry`] を構築する。ディレクトリは作成しない
    /// (構築は副作用なし。作成は [`save`](Self::save) のみ)。`HOME`/`USERPROFILE` のいずれも
    /// 未設定な場合は [`ModelError::CacheDirUnavailable`] を返す。
    ///
    /// **Windows での対応状況**: 本関数によるキャッシュルート解決
    /// 自体は Windows でも動作するが、[`load`](Self::load)・
    /// [`available_models`](Self::available_models) は Windows では
    /// 常に失敗(もしくは空の一覧)を返す fail-closed 非対応である
    /// (モジュール doc「Windows 対応状況」節参照)。
    pub fn new() -> Result<Self, ModelError> {
        let home = std::env::var_os("HOME");
        let userprofile = std::env::var_os("USERPROFILE");
        let selected = select_home_var(cfg!(windows), home, userprofile);
        let root = default_cache_dir_from(selected).ok_or(ModelError::CacheDirUnavailable)?;
        Ok(Self { root })
    }

    /// キャッシュルートを明示指定して [`ModelRegistry`] を構築する
    /// (統合テスト・CI・非標準配置向け。実 `$HOME` に触れずに
    /// テストするための入口。環境変数の書き換えは `cargo test` の
    /// 並列実行でプロセスグローバル競合を起こすため採用しない)。
    pub fn with_cache_dir(dir: impl Into<PathBuf>) -> Self {
        Self { root: dir.into() }
    }

    /// キャッシュルートのパスを返す。
    pub fn cache_dir(&self) -> &Path {
        &self.root
    }

    /// `<cache_dir>/<name>/<version>/model.safetensors` をシンボリック
    /// リンク経由のキャッシュルート脱出・TOCTOU・非通常ファイルの
    /// 受理を許さずに解決し、開いた [`File`] ハンドルを返す(OWASP
    /// A03。codex-review 指摘・PR #2226。手順の詳細はモジュール doc
    /// 「非信頼入力の扱い」節を参照)。`load`・
    /// [`available_models`](Self::available_models) の両方が同一の
    /// この関数を経由するため、列挙結果は必ず `load` が受理するパス
    /// のみを含む。
    ///
    /// 権限エラー等それ以外の I/O 失敗は [`ModelError::Io`] として
    /// 伝える。
    ///
    /// サイズ上限は本番経路では常に `MAX_MODEL_FILE_BYTES`
    /// ([`Self::resolve_model_file`] 経由)だが、`max` を引数化する
    /// ことで単体テストが実際の 1 GiB ファイルを用意せずに fstat 経路の
    /// 上限超過拒否を検証できるようにする(`crate::model::tests::
    /// resolve_model_file_with_limit_rejects_over_bound` 参照)。
    fn resolve_model_file_with_limit(
        &self,
        name: &str,
        version: &str,
        max: u64,
    ) -> Result<File, ModelError> {
        validate_component("name", name)?;
        validate_component("version", version)?;

        let not_found = || ModelError::NotFound {
            name: name.to_string(),
            version: version.to_string(),
        };

        let canonical_root = match self.root.canonicalize() {
            Ok(p) => p,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found()),
            Err(e) => return Err(ModelError::Io(e)),
        };

        let name_dir = self.root.join(name);
        let version_dir = name_dir.join(version);
        let leaf = version_dir.join(MODEL_FILE_NAME);

        // 中間ディレクトリ(name・version): シンボリックリンク拒否+
        // ディレクトリ型必須。
        for component in [&name_dir, &version_dir] {
            let meta = match std::fs::symlink_metadata(component) {
                Ok(m) => m,
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found()),
                Err(e) => return Err(ModelError::Io(e)),
            };
            let file_type = meta.file_type();
            if file_type.is_symlink() || !file_type.is_dir() {
                return Err(not_found());
            }
        }

        // 葉ファイル: シンボリックリンク拒否+**通常ファイルであることを
        // 明示要求**(`!is_dir()` ではなく `is_file()`。FIFO・Unix
        // ソケット・デバイスファイル等は通常ファイルではないため拒否
        // される。codex-review 指摘・PR #2226)。`leaf_meta` は手順 4
        // の識別子一致検査で使うため保持する。
        let leaf_meta = match std::fs::symlink_metadata(&leaf) {
            Ok(m) => m,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found()),
            Err(e) => return Err(ModelError::Io(e)),
        };
        if leaf_meta.file_type().is_symlink() || !leaf_meta.is_file() {
            return Err(not_found());
        }

        // 多層防御: canonicalize 後も正規化済みルート配下であることを
        // 確認する(この時点はまだ検査のスナップショットであり単独では
        // TOCTOU を閉じない。手順 3〜4 で最終的に閉じる)。
        let canonical_leaf = leaf.canonicalize().map_err(ModelError::Io)?;
        if !canonical_leaf.starts_with(&canonical_root) {
            return Err(not_found());
        }

        // シンボリックリンク追跡なし・非ブロッキングで葉を開く
        // (`open_leaf_no_follow`)。ELOOP(O_NOFOLLOW がシンボリック
        // リンクへ差し替えられた葉を検出した場合)は
        // `ErrorKind::FilesystemLoop` として報告される。
        let file = match open_leaf_no_follow(&leaf) {
            Ok(f) => f,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found()),
            #[cfg(any(
                all(
                    target_os = "linux",
                    any(target_arch = "x86_64", target_arch = "aarch64")
                ),
                target_os = "macos"
            ))]
            Err(e) if e.raw_os_error() == Some(crate::fs_guard::open_flags::ELOOP) => {
                return Err(not_found());
            }
            Err(e) => return Err(ModelError::Io(e)),
        };

        // 検査と open のハンドル一体化: 開いたハンドルの fstat が
        // 通常ファイルであること、かつ Unix では手順 2 で lstat した
        // 葉と同一の実体(デバイス番号+inode 番号)であることを
        // 確認する。手順 2〜ここまでの間に name/version/葉のいずれか
        // が別ファイルへ差し替えられていた場合、開かれた実体の識別子は
        // 検査時点のものと一致しないためここで確実に検出できる
        // (TOCTOU 対策。モジュール doc「非信頼入力の扱い」節参照)。
        let open_meta = file.metadata().map_err(ModelError::Io)?;
        if !open_meta.is_file() {
            return Err(not_found());
        }
        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            if open_meta.dev() != leaf_meta.dev() || open_meta.ino() != leaf_meta.ino() {
                return Err(not_found());
            }
        }

        // ファイルサイズ上限検査(モジュール doc「非信頼入力の扱い」節
        // 手順 5。メモリ枯渇対策・codex-review 指摘・PR #2226・P0)。
        // 開いたハンドルの fstat 実測値で判定するため、手順 1〜4 の
        // 検査対象と同一の実体(TOCTOU 対策で確認済み)のサイズを見る。
        // ここを通過しても手順 6(`load` 側の `take` 二段構え)が
        // fstat 後の追記・差し替えによる増大を別途検出する。
        enforce_size_limit(name, version, open_meta.len(), max)?;

        Ok(file)
    }

    /// [`Self::resolve_model_file_with_limit`] を本番経路の固定上限
    /// `MAX_MODEL_FILE_BYTES` で呼び出す薄いラッパー。
    fn resolve_model_file(&self, name: &str, version: &str) -> Result<File, ModelError> {
        self.resolve_model_file_with_limit(name, version, MAX_MODEL_FILE_BYTES)
    }

    /// `<cache_dir>/<name>/<version>/model.safetensors` を同期ロードし、
    /// state dict(キー = テンソル名)を返す。
    ///
    /// パス解決・オープンは `resolve_model_file`(シンボリックリンク
    /// 経由のキャッシュルート脱出・TOCTOU・非通常ファイル対策。
    /// モジュール doc「非信頼入力の扱い」節参照)に委譲する。存在
    /// しない、通常ファイルでない、または脱出と判定された場合は
    /// [`ModelError::NotFound`] に丸める。権限エラー等それ以外の
    /// I/O 失敗は [`ModelError::Io`]。開いた同一ハンドルから読み
    /// 取ったバイト列を
    /// [`load_safetensors_f32_from_bytes`]
    /// (`crate::interop::safetensors`。#2019)へ委譲する(パスで
    /// 再度 open し直すと `resolve_model_file` が閉じた TOCTOU 窓が
    /// 復活するため、ハンドルの使い回しは必須。#2019)。転置・
    /// キーリネーム等の暗黙アダプタは一切行わない(REQ-7 契約は
    /// `interop::safetensors` に一元化済みでロジックを複製しない)。
    pub fn load(
        &self,
        name: &str,
        version: &str,
    ) -> Result<HashMap<String, Tensor<f32>>, ModelError> {
        self.load_with_limit(name, version, MAX_MODEL_FILE_BYTES)
    }

    /// [`Self::load`] の本体。サイズ上限 `max` を引数化することで、
    /// 単体テストが実際の `MAX_MODEL_FILE_BYTES`(1 GiB)ファイルを
    /// 用意せずに「fstat 通過後の読み取り自体が上限超過を検出する」
    /// 手順 6 の TOCTOU 対策(`Read::take` 二段構え)を検証できる
    /// (`#[cfg(test)]` `load_with_limit_rejects_read_exceeding_bound`
    /// 参照。本番経路は `load` から `MAX_MODEL_FILE_BYTES` 固定で
    /// 呼ばれる)。
    fn load_with_limit(
        &self,
        name: &str,
        version: &str,
        max: u64,
    ) -> Result<HashMap<String, Tensor<f32>>, ModelError> {
        let mut file = self.resolve_model_file_with_limit(name, version, max)?;
        // `resolve_model_file_with_limit` の fstat 時点では `max`
        // 以内であることを確認済みだが、その後ファイルが差し替え・
        // 追記されて増大する TOCTOU に備え、読み取り自体も
        // `max + 1` バイトで打ち切る(モジュール doc「非信頼入力の
        // 扱い」節手順 6。ちょうど上限バイト数で打ち切ると超過を
        // 検出できないため `+ 1` 分だけ多く読む)。
        let limit = max.saturating_add(1);
        // 事前確保サイズは実測ファイルサイズ(`limit` 未満なら実測値、
        // 以上なら `limit`)を使う。`limit`(本番経路では 1 GiB + 1)
        // を毎回丸ごと事前確保すると小さい正規ファイルの読み込みでも
        // 無駄に大きな割り当てが発生するため、実測値を優先しつつ
        // `try_reserve` で割り当て失敗を panic ではなく型付きエラーへ
        // 変換する(実体は `reserve_read_buffer`)。
        let observed_len = file.metadata().map_err(ModelError::Io)?.len();
        let capacity_hint = usize::try_from(observed_len.min(limit)).unwrap_or(usize::MAX);
        let mut bytes = reserve_read_buffer(capacity_hint)?;
        file.by_ref()
            .take(limit)
            .read_to_end(&mut bytes)
            .map_err(ModelError::Io)?;
        enforce_size_limit(name, version, bytes.len() as u64, max)?;
        load_safetensors_f32_from_bytes(&bytes).map_err(ModelError::Load)
    }

    /// `tensors`(state dict)を `<cache_dir>/<name>/<version>/model.safetensors`
    /// へ保存する([`load`](Self::load) の書き込み側の対。イシュー #3119・
    /// `docs/facade-model-registry-decision.md` §16)。既存ファイルは原子的に
    /// 置き換える(上書きフラグは持たない)。
    ///
    /// # 手順(順序固定)
    ///
    /// 1. 対応プラットフォーム判定(範囲外は副作用の前に `Unsupported`)
    /// 2. `name`・`version` の許可文字集合検証、空集合・サイズ上限の検査、
    ///    safetensors へのエンコードと読み戻しによる整合確認(ここまで
    ///    ファイルシステムには触れない)
    /// 3. ルートを開き、`<name>`・`<version>` ディレクトリを 1 段ずつ用意して
    ///    ディレクトリ fd を保持する(シンボリックリンク・非ディレクトリは拒否。
    ///    `create_dir_all` は使わない)
    /// 4. 保持した `<version>` の fd 配下の一時ファイル(`.model.safetensors.tmp-*`・
    ///    `create_new`)へ書き込み、実体識別子を再確認してから同じ fd 配下で
    ///    `rename`(唯一のコミット点)
    /// 5. `load` と同じ経路で開き直し、確定したファイルが書き込んだ実体で
    ///    あることを確認する
    ///
    /// 失敗時は自分が作った一時ファイルだけを(`(dev, ino)` で所有を確かめて)
    /// 削除し、既存の `model.safetensors` には触れない。作成済みの
    /// ディレクトリは削除しない。
    ///
    /// # `load` との往復契約
    ///
    /// `Ok(())` なら同じ `name`・`version` の [`load`](Self::load) が同じキーと
    /// 値(F32 の bit 一致)を返す。F32 限定は型 `Tensor<f32>` で保証される。
    ///
    /// # プラットフォーム
    ///
    /// Linux の x86_64/aarch64 のみ。保存先はディレクトリ fd で固定する
    /// (`/proc/self/fd/<fd>` 経由。std だけで `openat` 相当を得る手段)ため、
    /// `load` の対応範囲のうち macOS は対象外で、それ以外(Windows 等)と併せて
    /// 最初に `Io(Unsupported)` を返し何も作らない(範囲外では不正な `name`・
    /// `version` も `InvalidComponent` ではなく `Unsupported` になるが、いずれも
    /// 書き込み前の拒否である)。`/proc` が使えない Linux でも書き込み前に `Err`
    ///
    /// # 保存先の固定・並行性・耐久性
    ///
    /// - キャッシュルートを開いて得たディレクトリ fd を基準に `<name>`・`<version>` を
    ///   1 段ずつ `O_NOFOLLOW|O_DIRECTORY` で開き、一時ファイルの作成と `rename` は
    ///   その fd 配下だけで行う。検査後に `<name>`・`<version>` のパス文字列が
    ///   シンボリックリンクへ差し替えられても、書き込みは開いた時点の実体に
    ///   留まりルート外へ出ない(差し替えを検出した場合は確定後の検証が `Err` を返す。
    ///   `docs/facade-model-registry-decision.md` §16.6・§16.9)。ルート自体の
    ///   シンボリックリンクは利用者の選択として許容する
    /// - 並行する `save` は「最後に `rename` した側が勝つ」。調停は
    ///   サポート対象外
    /// - `fsync` はしない(電源断に対する耐久性は保証しない。原子性は
    ///   `rename` による)
    ///
    /// # 例
    ///
    /// 一時ディレクトリをキャッシュルートにして保存・再読込する(`$HOME` には
    /// 触れない)。対応外プラットフォーム(macOS・Windows 等)では `Unsupported`
    /// になるため何もしない。
    ///
    /// ```
    /// use fandhe_ai::Tensor;
    /// use fandhe_ai::model::{ModelError, ModelRegistry};
    /// use std::collections::HashMap;
    ///
    /// let nanos = std::time::SystemTime::now()
    ///     .duration_since(std::time::UNIX_EPOCH)
    ///     .map(|d| d.as_nanos())
    ///     .unwrap_or(0);
    /// let root = std::env::temp_dir().join(format!(
    ///     "fandhe-ai-model-save-doc-{}-{nanos}",
    ///     std::process::id()
    /// ));
    /// let registry = ModelRegistry::with_cache_dir(root.clone());
    /// let mut state = HashMap::new();
    /// state.insert("w".to_string(), Tensor::new(vec![1.0_f32, 2.0], &[2])?);
    /// match registry.save("mlp", "v1", &state) {
    ///     Ok(()) => {
    ///         let loaded = registry.load("mlp", "v1")?;
    ///         assert_eq!(loaded.len(), 1);
    ///         assert_eq!(
    ///             registry.available_models(),
    ///             vec![("mlp".to_string(), vec!["v1".to_string()])]
    ///         );
    ///     }
    ///     Err(ModelError::Io(e)) if e.kind() == std::io::ErrorKind::Unsupported => {}
    ///     Err(e) => return Err(e.into()),
    /// }
    /// let _ = std::fs::remove_dir_all(&root);
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    ///
    /// # Errors
    ///
    /// - [`ModelError::InvalidComponent`] — `name`・`version` が許可文字集合外
    /// - [`ModelError::EmptyModel`] — `tensors` が空
    /// - [`ModelError::TooLarge`] — 概算またはエンコード後のサイズが上限(1 GiB)超過
    /// - [`ModelError::Save`] — safetensors エンコード失敗
    /// - [`ModelError::Load`] — 読み戻し時のデコード失敗
    /// - [`ModelError::Io`] — 上記以外。読み戻しの不一致・確定後の実体不一致は
    ///   `InvalidData`、中間段がシンボリックリンクまたは非ディレクトリなら
    ///   `InvalidInput`、非対応プラットフォームは `Unsupported`。メッセージに
    ///   パスは含めない
    pub fn save(
        &self,
        name: &str,
        version: &str,
        tensors: &HashMap<String, Tensor<f32>>,
    ) -> Result<(), ModelError> {
        #[cfg(all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ))]
        {
            self.save_with(
                name,
                version,
                tensors,
                MAX_MODEL_FILE_BYTES,
                production_tmp_name,
                |_: &Path| Ok(()),
            )
        }
        #[cfg(not(all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        )))]
        {
            let _ = (name, version, tensors);
            Err(ModelError::Io(std::io::Error::from(
                std::io::ErrorKind::Unsupported,
            )))
        }
    }

    /// [`Self::save`] の本体。サイズ上限・一時名生成器・確定直前フックを
    /// 引数化し、単体テストが 1 GiB 級のデータなしに上限超過・名前衝突・
    /// 途中失敗を再現できるようにする(`compat::model_io` の
    /// `write_prepared_with` と同じ差し込み方)。本番は `save` から
    /// `MAX_MODEL_FILE_BYTES`・`production_tmp_name`・何もしないフックで呼ぶ。
    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    fn save_with(
        &self,
        name: &str,
        version: &str,
        tensors: &HashMap<String, Tensor<f32>>,
        max: u64,
        mut next_tmp: impl FnMut() -> String,
        before_commit: impl FnOnce(&Path) -> std::io::Result<()>,
    ) -> Result<(), ModelError> {
        use crate::fs_write::{MAX_TMP_NAME_ATTEMPTS, create_new_with_retry, remove_own_tmp};
        use crate::interop::safetensors::save_safetensors_f32_to_bytes;
        use std::io::{Error as IoError, ErrorKind, Write};
        use std::os::unix::fs::MetadataExt;

        validate_component("name", name)?;
        validate_component("version", version)?;
        if tensors.is_empty() {
            return Err(ModelError::EmptyModel {
                name: name.to_string(),
                version: version.to_string(),
            });
        }

        // エンコード前の概算(F32 データ部のみ)。巨大入力のエンコードで
        // メモリを確保する前に拒否する。overflow は上限超過として扱う。
        let mut estimate: u64 = 0;
        for t in tensors.values() {
            match (t.numel() as u64)
                .checked_mul(4)
                .and_then(|b| estimate.checked_add(b))
            {
                Some(v) => estimate = v,
                None => {
                    return Err(ModelError::TooLarge {
                        name: name.to_string(),
                        version: version.to_string(),
                        len: u64::MAX,
                        max,
                    });
                }
            }
        }
        enforce_size_limit(name, version, estimate, max)?;

        let bytes = save_safetensors_f32_to_bytes(tensors, None).map_err(ModelError::Save)?;
        enforce_size_limit(name, version, bytes.len() as u64, max)?;

        // 読み戻し検証: 保存できたのに `load` で読めないファイルを作らない。
        {
            let back = load_safetensors_f32_from_bytes(&bytes).map_err(ModelError::Load)?;
            let consistent = back.len() == tensors.len()
                && tensors
                    .iter()
                    .all(|(k, t)| back.get(k).is_some_and(|b| b.shape() == t.shape()));
            if !consistent {
                return Err(ModelError::Io(IoError::new(
                    ErrorKind::InvalidData,
                    "エンコード結果の読み戻しがキー集合または shape と一致しません",
                )));
            }
        }

        // ここからファイルシステムに触れる。ルート自体がシンボリックリンク
        // でも許容する(利用者が選んだ配置。`load` 側と同じ扱い)。
        ensure_proc_fd_usable(Path::new("/proc/self/fd")).map_err(ModelError::Io)?;
        std::fs::create_dir_all(&self.root).map_err(ModelError::Io)?;
        // ディレクトリ fd を 1 段ずつ固定し、以後の一時ファイル作成・`rename` は
        // `/proc/self/fd/<fd>` 配下だけで行う(パス文字列の再解決をしない。
        // codex-review 指摘・PR #3377 P0。差し替えられても書き込みはルート外へ出ない)。
        let root_dir = open_root_dir(&self.root).map_err(ModelError::Io)?;
        let name_fd = open_dir_component(&root_dir, name).map_err(ModelError::Io)?;
        let version_fd = open_dir_component(&name_fd, version).map_err(ModelError::Io)?;
        let version_dir = proc_fd_path(&version_fd);

        let (mut tmp_file, tmp_name) =
            create_new_with_retry(&version_dir, &mut next_tmp, MAX_TMP_NAME_ATTEMPTS)
                .map_err(ModelError::Io)?;
        let tmp_path = version_dir.join(&tmp_name);
        let leaf = version_dir.join(MODEL_FILE_NAME);

        let committed: std::io::Result<()> = (|| {
            tmp_file.write_all(&bytes)?;
            // 確定直前の再確認: 一時ファイルが保持ハンドルと同一の実体であること。
            let held = tmp_file.metadata()?;
            let on_disk = std::fs::symlink_metadata(&tmp_path)?;
            if !on_disk.file_type().is_file()
                || on_disk.dev() != held.dev()
                || on_disk.ino() != held.ino()
            {
                return Err(IoError::new(
                    ErrorKind::InvalidData,
                    "一時ファイルが書き込み中に差し替えられました",
                ));
            }
            before_commit(&tmp_path)?;
            std::fs::rename(&tmp_path, &leaf)
        })();
        if let Err(e) = committed {
            // 削除してよいのはこの呼び出しが作って保持している一時ファイルだけ。
            remove_own_tmp(&tmp_file, &tmp_path);
            return Err(ModelError::Io(e));
        }

        // 確定後の検証(`rename` 後なので `model.safetensors` は消さない)。
        let held = tmp_file.metadata().map_err(ModelError::Io)?;
        let committed_file = match self.resolve_model_file_with_limit(name, version, max) {
            Ok(f) => f,
            Err(ModelError::NotFound { .. }) => {
                return Err(ModelError::Io(IoError::new(
                    ErrorKind::InvalidData,
                    "保存直後のモデルファイルを解決できません",
                )));
            }
            Err(e) => return Err(e),
        };
        let now = committed_file.metadata().map_err(ModelError::Io)?;
        if now.dev() != held.dev() || now.ino() != held.ino() {
            return Err(ModelError::Io(IoError::new(
                ErrorKind::InvalidData,
                "確定したファイルが書き込んだ実体と一致しません",
            )));
        }
        Ok(())
    }

    /// HTTPS URL から safetensors を取得し、`<cache_dir>/<name>/<version>/model.safetensors`
    /// へ原子的に置く(`torch.hub.load_state_dict_from_url`/Keras `get_file` 相当。#3125)。
    /// pin 無しの [`Self::download_with`] への委譲。
    ///
    /// # 保証範囲
    ///
    /// pin([`DownloadOptions::expected_sha256`])を指定しない場合の保証は破損・部分改変の
    /// 検出(サイズ上限・`Content-Length` 一致)に限られ、意図的な改竄に対する真正性は保証
    /// しない。真正性が必要なら pin を指定すること。公開前に
    /// safetensors の構造(ヘッダ長・JSON・オフセット整合)を検証し、不正な内容は
    /// [`ModelError::Load`] で拒否して既存ファイルを変更しない(検証は dtype に依存しない
    /// レイアウト検証で、F16/BF16 の正当なファイルも公開できる。`load` は従来どおり F32 のみ)。
    ///
    /// # キャッシュ(#3126)
    ///
    /// 取得に成功すると `<name>/<version>/download.json`(`url`・`etag`・`content_length`・
    /// `sha256`・`fetched_at`)を `model.safetensors` の後に置く。同じ URL の再取得で
    /// `ETag` が保存されていれば `If-None-Match` を付けた条件付き GET を送り、`304` を受けたら
    /// 既存 `model.safetensors` の SHA-256 を計算し直して pin(なければ保存値)と照合し、一致
    /// したときだけ再利用して `fetched_at` のみを更新する。一致しない・ファイルが無い・
    /// `download.json` が壊れている場合は無条件 GET で全量を取り直す。保存値は破損・部分改変の
    /// 検出用で、意図的な改竄に対する真正性は pin でしか保証できない。`ETag` を返さない
    /// サーバからは常に取り直す。`download.json` の書き込みだけが失敗した場合もエラーを返す
    /// (モデル本体は検証済みで公開済み。古い `download.json` は次回の再検証で不一致となり
    /// 取り直しになる)。
    /// SSRF(内部ネットワーク宛先)の遮断は呼び出し側の責務で、本関数が担保するのは
    /// `https` 限定・資格情報付き URL の拒否・各リダイレクトでの再検証までである。
    ///
    /// 通常の呼び出し(`fetch` は実行しない。コンパイルのみ検証する)と、ネットワーク・FS を
    /// 使わずに実行される拒否例(`http://` は接続前に `InvalidUrl`):
    ///
    /// ```
    /// use fandhe_ai::model::{ModelError, ModelRegistry};
    /// fn fetch(registry: &ModelRegistry) -> Result<(), ModelError> {
    ///     registry.download("https://example.com/mlp.safetensors", "mlp", "v1")
    /// }
    /// let _ = fetch;
    /// let registry = ModelRegistry::with_cache_dir("/nonexistent/fandhe-models-doctest");
    /// // 非 unix は fail-closed で非対応(URL 検証前に `Io(Unsupported)`)のため unix のみ検証する。
    /// #[cfg(unix)]
    /// {
    ///     let err = registry
    ///         .download("http://example.com/mlp.safetensors", "mlp", "v1")
    ///         .unwrap_err();
    ///     assert!(matches!(err, ModelError::InvalidUrl { .. }));
    /// }
    /// ```
    ///
    /// # Errors
    ///
    /// [`Self::download_with`] を参照。
    pub fn download(&self, url: &str, name: &str, version: &str) -> Result<(), ModelError> {
        self.download_with(url, name, version, DownloadOptions::default())
    }

    /// [`Self::download`] のオプション付き版。取得は 1 GiB 上限・リダイレクト 5 ホップ・全体
    /// 1 時間の固定既定で行い、TLS は OS のルート証明書で検証する(無効化する口はない)。
    ///
    /// 通常の呼び出しは `fetch`(実行しない。コンパイルのみ検証)。実行部分は pin・進捗付きの
    /// オプションを組み立て、`http://` が接続前に `InvalidUrl` となることを示す
    /// (ネットワーク・FS は使わない):
    ///
    /// ```
    /// use fandhe_ai::model::{
    ///     DownloadOptions, DownloadProgress, ModelError, ModelRegistry, Sha256Pin,
    /// };
    /// fn fetch(registry: &ModelRegistry, opts: DownloadOptions<'_>) -> Result<(), ModelError> {
    ///     registry.download_with("https://example.com/mlp.safetensors", "mlp", "v1", opts)
    /// }
    /// let _ = fetch;
    /// let registry = ModelRegistry::with_cache_dir("/nonexistent/fandhe-models-doctest");
    /// let pin = Sha256Pin::from_hex(&"0".repeat(64)).unwrap();
    /// let mut on_progress = |p: DownloadProgress| {
    ///     let _ = (p.received, p.total);
    /// };
    /// let opts = DownloadOptions::default()
    ///     .expected_sha256(pin)
    ///     .progress(&mut on_progress);
    /// // 非 unix は fail-closed で非対応(`Io(Unsupported)`)のため unix のみ検証する。
    /// #[cfg(unix)]
    /// {
    ///     let err = registry
    ///         .download_with("http://example.com/mlp.safetensors", "mlp", "v1", opts)
    ///         .unwrap_err();
    ///     assert!(matches!(err, ModelError::InvalidUrl { .. }));
    /// }
    /// ```
    ///
    /// # Errors
    ///
    /// - [`ModelError::InvalidComponent`] — `name`・`version` が許可文字集合外
    /// - [`ModelError::InvalidUrl`] — URL が `https` 絶対形でない・資格情報付き等
    /// - [`ModelError::Http`]・[`ModelError::TooManyRedirects`]・[`ModelError::Network`]
    /// - [`ModelError::TooLarge`] — `Content-Length` または受信量が上限(1 GiB)超過
    /// - [`ModelError::ContentLengthMismatch`]・[`ModelError::HashMismatch`]
    /// - [`ModelError::Load`] — 取得内容が妥当な safetensors でない
    /// - [`ModelError::StorageFull`]・[`ModelError::PermissionDenied`] — ダウンロード経路の
    ///   書き込みでディスク満杯・権限エラー
    /// - [`ModelError::CacheMetadata`] — 書き込もうとした `download.json` が不正(読み出し側の
    ///   破損は再取得にフォールバックするため返らない)
    /// - [`ModelError::Io`] — 上記以外の書き込み失敗。中間ディレクトリがシンボリックリンクなら
    ///   `InvalidInput`、非対応プラットフォーム(unix 以外)は `Unsupported`
    ///
    /// 失敗時は既存の `model.safetensors` を変更せず、自分が作った一時ファイルだけを削除する。
    pub fn download_with(
        &self,
        url: &str,
        name: &str,
        version: &str,
        options: DownloadOptions<'_>,
    ) -> Result<(), ModelError> {
        validate_component("name", name)?;
        validate_component("version", version)?;
        #[cfg(unix)]
        {
            let url = url::validate_https_url(url)?;
            let mut transport = transport::UreqTransport::new()?;
            self.download_via(
                &mut transport,
                url,
                name,
                version,
                options,
                MAX_MODEL_FILE_BYTES,
            )
        }
        #[cfg(not(unix))]
        {
            let _ = (url, options);
            Err(ModelError::Io(std::io::Error::from(
                std::io::ErrorKind::Unsupported,
            )))
        }
    }

    /// [`Self::download_with`] の本体。転送・サイズ上限を引数化し、単体テストがソケットも
    /// 1 GiB のデータも使わずに結線(ディレクトリ作成・公開)を検証できるようにする。
    #[cfg(unix)]
    fn download_via(
        &self,
        transport: &mut dyn transport::Transport,
        url: url::ValidatedUrl,
        name: &str,
        version: &str,
        options: DownloadOptions<'_>,
        max_bytes: u64,
    ) -> Result<(), ModelError> {
        let DownloadOptions {
            expected_sha256,
            progress,
        } = options;
        let root = self.root.clone();
        let mut open_dir = || -> std::io::Result<std::os::fd::OwnedFd> {
            let root_fd = dirfd::open_root(&root)?;
            let name_fd = dirfd::open_or_create_dir(&root_fd, name)?;
            dirfd::open_or_create_dir(&name_fd, version)
        };
        // 作成しない参照(キャッシュ参照・`304` 再検証用)。不在は `None`。
        let mut lookup_dir = || -> std::io::Result<Option<std::os::fd::OwnedFd>> {
            let Some(root_fd) = dirfd::open_root_existing(&root)? else {
                return Ok(None);
            };
            let Some(name_fd) = dirfd::open_existing_dir(&root_fd, name)? else {
                return Ok(None);
            };
            dirfd::open_existing_dir(&name_fd, version)
        };
        // キャッシュ参照段: `download.json` が読めて、URL 一致・etag あり・pin 整合のときだけ
        // 条件付き GET の根拠にする(pin があれば保存値が pin と食い違うキャッシュは使わない。
        // A08)。読めない・不正なら `None`(無条件 GET へフォールバック)。
        let cache_meta = lookup_dir()
            .ok()
            .flatten()
            .and_then(|d| cache::read(&d))
            .filter(|m| {
                m.url == url.as_str()
                    && m.etag.is_some()
                    && expected_sha256.is_none_or(|pin| m.sha256 == pin)
            });
        let mut next_staging = || dirfd::production_staging_name(MODEL_FILE_NAME);
        let mut next_tmp = || dirfd::production_tmp_name(MODEL_FILE_NAME);
        let mut now = || {
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map_or(0, |d| d.as_secs())
        };
        let job = download::Job {
            url,
            name,
            version,
            max_bytes,
            max_redirects: DOWNLOAD_MAX_REDIRECTS,
            deadline: std::time::Instant::now() + transport::OVERALL_DEADLINE,
            expected: expected_sha256,
            cache: cache_meta,
        };
        // 公開直前の検証フック: safetensors のヘッダ長・JSON・オフセット整合を検証する
        // (方式 (ii)・設計 §5/§6 A08)。不正なら rename 前に失敗し既存ファイルは置き換わらない。
        download::run(
            transport,
            job,
            progress,
            download::Hooks {
                lookup_dir: &mut lookup_dir,
                open_dir: &mut open_dir,
                next_staging: &mut next_staging,
                next_tmp: &mut next_tmp,
                verify_before_commit: &mut verify_safetensors_before_commit,
                now: &mut now,
            },
        )
    }

    /// キャッシュルート配下に存在するモデルを列挙する。ルートが
    /// 存在しない、または読み取れない場合はレジストリが空とみなし
    /// 空 `Vec` を返す(エラー型を返さないのは戻り値型の契約による。
    /// [`load`](Self::load) 側の I/O エラー区別とは異なる扱い)。
    ///
    /// 列挙規則:
    /// - ルート直下の**ディレクトリ**([`std::fs::DirEntry::file_type`]
    ///   はリンクを辿らないため、シンボリックリンクは対象外)で名前が
    ///   `validate_component` を通るものを `name` 候補とする
    ///   (ファイル・検証不合格名はスキップ)
    /// - `name` 直下のディレクトリで名前が検証を通り、かつ
    ///   `resolve_model_file` が受理するものだけを `version` として
    ///   列挙する(`load` が受理するパスのみを列挙する一貫性保証。
    ///   モジュール doc「非信頼入力の扱い」節参照)
    /// - version が 1 件もない `name` は結果に含めない
    /// - `name`・`versions` とも文字列昇順ソート(決定的出力)
    /// - ディレクトリ名は UTF-8 変換できるもののみ対象
    ///   (非 UTF-8 名はスキップ)
    pub fn available_models(&self) -> Vec<(String, Vec<String>)> {
        let Ok(entries) = std::fs::read_dir(&self.root) else {
            return Vec::new();
        };
        let mut result: Vec<(String, Vec<String>)> = Vec::new();
        for entry in entries.flatten() {
            let Ok(name) = entry.file_name().into_string() else {
                continue;
            };
            if validate_component("name", &name).is_err() {
                continue;
            }
            // `file_type()` はリンクを辿らない(`Path::is_dir` は辿る
            // ため使わない)。シンボリックリンクの `name` ディレクトリは
            // 列挙対象外とする。
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            if !file_type.is_dir() {
                continue;
            }
            let name_dir = entry.path();
            let Ok(version_entries) = std::fs::read_dir(&name_dir) else {
                continue;
            };
            let mut versions: Vec<String> = Vec::new();
            for version_entry in version_entries.flatten() {
                let Ok(version) = version_entry.file_name().into_string() else {
                    continue;
                };
                if validate_component("version", &version).is_err() {
                    continue;
                }
                if self.resolve_model_file(&name, &version).is_ok() {
                    versions.push(version);
                }
            }
            if versions.is_empty() {
                continue;
            }
            versions.sort();
            result.push((name, versions));
        }
        result.sort_by(|a, b| a.0.cmp(&b.0));
        result
    }
}

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

    /// 確保失敗(`TryReserveError`)が `ModelError::Io` の
    /// `ErrorKind::OutOfMemory` へヒープ確保なしで写ることを検証する
    /// (#2250)。`usize::MAX` は本番の
    /// `usize::try_from(..).unwrap_or(usize::MAX)` 由来の飽和値と同じ
    /// であり、合成的でない実経路の入力。`Vec<u8>` は要素サイズ 1
    /// バイトのため `usize::MAX` バイトの確保要求は `isize::MAX` を
    /// 超え、`try_reserve` はアロケータへ実際に触れる前に
    /// `TryReserveErrorKind::CapacityOverflow` を返す(32/64bit・
    /// 実メモリ量に依存しない決定的な失敗経路)。
    #[test]
    fn reserve_read_buffer_maps_alloc_failure_to_out_of_memory() {
        let err = reserve_read_buffer(usize::MAX).unwrap_err();
        match err {
            ModelError::Io(e) => {
                assert_eq!(e.kind(), std::io::ErrorKind::OutOfMemory);
                // `io::Error::other` はペイロードを `Some(..)` で
                // 保持する(Custom 表現)。ここが `None` であることが
                // 「ヒープ確保なしで構築した」Simple 表現である証拠
                // (旧実装〈`io::Error::other`〉との弁別)。
                assert!(e.get_ref().is_none());
            }
            other => panic!("ModelError::Io を期待したが {other:?} だった"),
        }
    }

    #[test]
    fn reserve_read_buffer_succeeds_for_small_capacity() {
        let bytes = reserve_read_buffer(16).expect("小さい確保要求は成功するべき");
        assert!(bytes.capacity() >= 16);
        assert_eq!(bytes.len(), 0);
    }

    #[test]
    fn enforce_size_limit_accepts_within_bound() {
        assert!(enforce_size_limit("mlp", "v1", 0, 100).is_ok());
        assert!(enforce_size_limit("mlp", "v1", 100, 100).is_ok());
    }

    #[test]
    fn enforce_size_limit_rejects_over_bound() {
        let err = enforce_size_limit("mlp", "v1", 101, 100).unwrap_err();
        match err {
            ModelError::TooLarge {
                name,
                version,
                len,
                max,
            } => {
                assert_eq!(name, "mlp");
                assert_eq!(version, "v1");
                assert_eq!(len, 101);
                assert_eq!(max, 100);
            }
            other => panic!("ModelError::TooLarge を期待したが {other:?} だった"),
        }
    }

    #[test]
    fn too_large_error_display_includes_sizes() {
        let err = enforce_size_limit("mlp", "v1", 101, 100).unwrap_err();
        let rendered = err.to_string();
        assert!(rendered.contains("101"), "rendered={rendered:?}");
        assert!(rendered.contains("100"), "rendered={rendered:?}");
    }

    #[test]
    fn validate_component_accepts_ascii_allowlist() {
        for value in ["a", "mlp", "v1", "1.2.3", "a_b-c.d", "A9"] {
            assert!(
                validate_component("name", value).is_ok(),
                "許可されるべき値が拒否された: {value:?}"
            );
        }
    }

    #[test]
    fn validate_component_rejects_traversal_and_unsafe_values() {
        for value in [
            "",
            ".",
            "..",
            "a/b",
            "a\\b",
            ".hidden",
            "a b",
            "a\0b",
            "/etc/passwd",
        ] {
            assert!(
                validate_component("name", value).is_err(),
                "拒否されるべき値が許可された: {value:?}"
            );
        }
    }

    #[test]
    fn default_cache_dir_from_home_appends_fixed_suffix() {
        let home = Some(OsString::from("/home/x"));
        let dir = default_cache_dir_from(home).unwrap();
        assert_eq!(dir, PathBuf::from("/home/x/.fandhe-ai/models"));
    }

    #[test]
    fn default_cache_dir_from_none_is_none() {
        assert!(default_cache_dir_from(None).is_none());
    }

    #[test]
    fn select_home_var_prefers_userprofile_on_windows() {
        let home = Some(OsString::from("/home/x"));
        let userprofile = Some(OsString::from(r"C:\Users\x"));
        assert_eq!(
            select_home_var(true, home.clone(), userprofile.clone()),
            userprofile
        );
        // Windows でも `USERPROFILE` 未設定なら `HOME` へフォールバックする。
        assert_eq!(
            select_home_var(true, home, None),
            Some(OsString::from("/home/x"))
        );
    }

    #[test]
    fn select_home_var_prefers_home_on_non_windows() {
        let home = Some(OsString::from("/home/x"));
        let userprofile = Some(OsString::from(r"C:\Users\x"));
        assert_eq!(
            select_home_var(false, home.clone(), userprofile.clone()),
            home
        );
        // 非 Windows でも `HOME` 未設定なら `USERPROFILE` へフォールバックする。
        assert_eq!(
            select_home_var(false, None, userprofile.clone()),
            userprofile
        );
    }

    #[test]
    fn select_home_var_none_when_both_unset() {
        assert_eq!(select_home_var(true, None, None), None);
        assert_eq!(select_home_var(false, None, None), None);
    }

    #[test]
    fn non_empty_os_string_filters_empty_value() {
        // 空文字列は「未設定」として扱う(Cursor Bugbot 指摘・PR #2226。
        // `HOME=""` をそのまま受理すると `PathBuf::from("")` がカレント
        // ディレクトリ相対のキャッシュルートを構築してしまう)。
        assert_eq!(non_empty_os_string(Some(OsString::from(""))), None);
    }

    #[test]
    fn non_empty_os_string_keeps_non_empty_value() {
        let value = Some(OsString::from("/home/x"));
        assert_eq!(non_empty_os_string(value.clone()), value);
    }

    #[test]
    fn non_empty_os_string_none_stays_none() {
        assert_eq!(non_empty_os_string(None), None);
    }

    #[test]
    fn select_home_var_none_when_both_empty_strings() {
        // 空文字列の HOME/USERPROFILE を有効値扱いすると
        // `default_cache_dir_from` が cwd 相対パスを構築してしまう
        // 回帰の直接検証(Cursor Bugbot 指摘・PR #2226)。フィルタは
        // `select_home_var` 内部で行われるため、ここで直接呼び出して
        // 検証する(`ModelRegistry::new` の内部ロジックを再現するの
        // ではなく、実際に呼ばれる関数そのものを検証する)。
        let empty = || Some(OsString::from(""));
        assert_eq!(select_home_var(true, empty(), empty()), None);
        assert_eq!(select_home_var(false, empty(), empty()), None);
        assert!(default_cache_dir_from(select_home_var(true, empty(), empty())).is_none());
    }

    #[test]
    fn select_home_var_falls_back_when_preferred_is_empty_string() {
        // 空文字列は「未設定」として扱われ、もう一方の値へフォールバック
        // する(`or` の優先順位ロジック自体は不変)。
        let value = Some(OsString::from("/x"));
        assert_eq!(
            select_home_var(false, Some(OsString::from("")), value.clone()),
            value
        );
        assert_eq!(
            select_home_var(true, value.clone(), Some(OsString::from(""))),
            value
        );
    }

    /// テスト用一時ディレクトリの RAII ガード(イシュー #2378・親 #2363)。
    /// `tests/common/temp_dir.rs::TempDirGuard` と同方式(一意名の `create_dir`
    /// 排他作成・事前削除なし・drop 時に作成分のみ削除)。`tests/common` は
    /// src から見えないため複製している。実際の 1 GiB ファイルを用意する
    /// 非現実的な手段は取らず、`resolve_model_file_with_limit`/
    /// `load_with_limit` の `max` 引数を小さく指定して上限超過を再現する。
    struct TempDirGuard {
        path: std::path::PathBuf,
    }

    impl TempDirGuard {
        fn new(label: &str) -> Self {
            use std::sync::atomic::{AtomicU64, Ordering};
            static COUNTER: AtomicU64 = AtomicU64::new(0);
            let pid = std::process::id();
            for _ in 0..64 {
                let nanos = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_nanos())
                    .unwrap_or(0);
                let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
                let candidate = std::env::temp_dir().join(format!(
                    "fandhe-ai-model-registry-unit-{pid}-{nanos}-{seq}-{label}"
                ));
                match std::fs::create_dir(&candidate) {
                    Ok(()) => return Self { path: candidate },
                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
                    Err(e) => panic!("一時ディレクトリを作成できない: {e}"),
                }
            }
            panic!("一意な一時ディレクトリ名を 64 回試行しても確保できない");
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TempDirGuard {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.path);
        }
    }

    fn write_leaf(dir: &Path, contents: &[u8]) {
        let version_dir = dir.join("mlp").join("v1");
        std::fs::create_dir_all(&version_dir).unwrap();
        std::fs::write(version_dir.join(MODEL_FILE_NAME), contents).unwrap();
    }

    /// `resolve_model_file_with_limit`(モジュール doc「非信頼入力の
    /// 扱い」節手順 5・fstat 時点の上限検査)が実ファイル経由で上限
    /// 超過を fail-closed に拒否することを検証する(PR #2226
    /// codex-review P0 是正。`enforce_size_limit_rejects_over_bound` は
    /// 純関数の境界値のみを検証するため、実際の fstat 経路が同じ判定
    /// へ到達することは別途要検証)。`max` を引数化しているため、
    /// `MAX_MODEL_FILE_BYTES`〈1 GiB〉相当の実ファイルを用意せずに
    /// 9 バイトのファイル・上限 8 バイトで同じ経路を再現できる。
    #[cfg(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    ))]
    #[test]
    fn resolve_model_file_with_limit_rejects_over_bound() {
        let dir = TempDirGuard::new("resolve-over-bound");
        write_leaf(dir.path(), b"123456789");

        let registry = ModelRegistry::with_cache_dir(dir.path());
        let err = registry
            .resolve_model_file_with_limit("mlp", "v1", 8)
            .unwrap_err();
        match err {
            ModelError::TooLarge {
                name,
                version,
                len,
                max,
            } => {
                assert_eq!(name, "mlp");
                assert_eq!(version, "v1");
                assert_eq!(len, 9);
                assert_eq!(max, 8);
            }
            other => panic!("ModelError::TooLarge を期待したが {other:?} だった"),
        }
    }

    /// `resolve_model_file_with_limit` は `len == max`(境界値)を
    /// 許可する(fstat 経路でも `enforce_size_limit_accepts_within_
    /// bound` と同じ境界を保つことの確認。安全側検証のため意図的に
    /// safetensors として不正な中身を使い、この後段の
    /// `load_safetensors_f32_from_bytes` 側のエラーとは切り分ける)。
    #[cfg(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    ))]
    #[test]
    fn resolve_model_file_with_limit_accepts_at_bound() {
        let dir = TempDirGuard::new("resolve-at-bound");
        write_leaf(dir.path(), b"12345678");

        let registry = ModelRegistry::with_cache_dir(dir.path());
        assert!(
            registry
                .resolve_model_file_with_limit("mlp", "v1", 8)
                .is_ok()
        );
    }

    /// `load_with_limit`([`ModelRegistry::load`] 本体)が `max` を
    /// 引数化した小さな実ファイルで上限超過を fail-closed に拒否する
    /// ことを、公開 `load` と同じ配線(`resolve_model_file_with_limit`
    /// → `Read::take` 二段構え)を通して検証する(モジュール doc
    /// 「非信頼入力の扱い」節手順 5〜6)。
    #[cfg(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    ))]
    #[test]
    fn load_with_limit_rejects_over_bound() {
        let dir = TempDirGuard::new("load-over-bound");
        write_leaf(dir.path(), b"123456789");

        let registry = ModelRegistry::with_cache_dir(dir.path());
        let err = registry.load_with_limit("mlp", "v1", 8).unwrap_err();
        assert!(
            matches!(err, ModelError::TooLarge { len: 9, max: 8, .. }),
            "ModelError::TooLarge {{ len: 9, max: 8, .. }} を期待したが {err:?} だった"
        );
    }

    /// 非対応プラットフォーム(Windows 等)の fail-closed 契約の回帰検査
    /// (モジュール doc の「load は `ModelError::Io`」記述を固定する)。
    /// open がサイズ判定より先に拒否するため、上限超過でも `TooLarge` ではなく
    /// `Io(Unsupported)` になる。述語の正は `fs_guard::open_leaf_no_follow`。
    #[cfg(not(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    )))]
    #[test]
    fn load_is_unsupported_on_non_supported_platform() {
        let dir = TempDirGuard::new("unsupported");
        write_leaf(dir.path(), b"12345678");
        let registry = ModelRegistry::with_cache_dir(dir.path());
        let err = registry
            .resolve_model_file_with_limit("mlp", "v1", 8)
            .unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::Unsupported),
            "Io(Unsupported) を期待したが {err:?} だった"
        );
        write_leaf(dir.path(), b"123456789");
        let err = registry
            .resolve_model_file_with_limit("mlp", "v1", 8)
            .unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::Unsupported),
            "TooLarge ではなく Io(Unsupported) を期待したが {err:?} だった"
        );
        let err = registry.load_with_limit("mlp", "v1", 8).unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::Unsupported),
            "Io(Unsupported) を期待したが {err:?} だった"
        );
    }

    // ---- `save`(イシュー #3119)の単体テスト。結合テストは #3120。----

    fn sample_state() -> HashMap<String, Tensor<f32>> {
        let mut m = HashMap::new();
        m.insert(
            "fc.weight".to_string(),
            Tensor::new(
                vec![0.1_f32, -0.2, 0.3, -0.4, f32::MIN_POSITIVE, 1e30],
                &[2, 3],
            )
            .unwrap(),
        );
        m.insert(
            "fc.bias".to_string(),
            Tensor::new(vec![-0.0_f32, 7.5], &[2]).unwrap(),
        );
        m
    }

    #[cfg(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    ))]
    fn bits(t: &Tensor<f32>) -> Vec<u32> {
        t.contiguous()
            .as_slice()
            .expect("contiguous は連続")
            .iter()
            .map(|v| v.to_bits())
            .collect()
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    fn tmp_leftovers(dir: &Path) -> Vec<String> {
        std::fs::read_dir(dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .filter(|n| n.starts_with(".model.safetensors.tmp-"))
            .collect()
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_then_load_roundtrips_bit_exact() {
        let dir = TempDirGuard::new("save-roundtrip");
        let registry = ModelRegistry::with_cache_dir(dir.path().join("root"));
        let state = sample_state();
        registry.save("mlp", "v1", &state).unwrap();
        let back = registry.load("mlp", "v1").unwrap();
        assert_eq!(back.len(), state.len());
        for (k, t) in &state {
            let b = back.get(k).expect("キーが往復するはず");
            assert_eq!(b.shape(), t.shape());
            assert_eq!(bits(b), bits(t), "{k}");
        }
        assert!(tmp_leftovers(&dir.path().join("root/mlp/v1")).is_empty());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_rejects_invalid_components_before_fs_access() {
        let dir = TempDirGuard::new("save-invalid");
        let root = dir.path().join("root");
        let registry = ModelRegistry::with_cache_dir(&root);
        let state = sample_state();
        for bad in ["", ".", "..", ".hidden", "a/b", "a\\b", "a\0b", "a\nb"] {
            assert!(
                matches!(
                    registry.save(bad, "v1", &state),
                    Err(ModelError::InvalidComponent { kind: "name", .. })
                ),
                "name={bad:?}"
            );
            assert!(
                matches!(
                    registry.save("mlp", bad, &state),
                    Err(ModelError::InvalidComponent {
                        kind: "version",
                        ..
                    })
                ),
                "version={bad:?}"
            );
        }
        assert!(!root.exists(), "検証失敗でルートを作ってはならない");
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_rejects_empty_model_without_creating_root() {
        let dir = TempDirGuard::new("save-empty");
        let root = dir.path().join("root");
        let registry = ModelRegistry::with_cache_dir(&root);
        let err = registry.save("mlp", "v1", &HashMap::new()).unwrap_err();
        assert!(matches!(err, ModelError::EmptyModel { .. }), "{err:?}");
        assert!(!root.exists());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_with_limit_rejects_over_bound_without_creating_root() {
        let dir = TempDirGuard::new("save-limit");
        let root = dir.path().join("root");
        let registry = ModelRegistry::with_cache_dir(&root);
        let err = registry
            .save_with("mlp", "v1", &sample_state(), 8, production_tmp_name, |_| {
                Ok(())
            })
            .unwrap_err();
        assert!(
            matches!(err, ModelError::TooLarge { max: 8, .. }),
            "{err:?}"
        );
        assert!(!root.exists());
        // エンコード後サイズだけが超える境界: 概算(F32 データ部)は通る上限。
        let data_bytes = 8 * 4;
        let err = registry
            .save_with(
                "mlp",
                "v1",
                &sample_state(),
                data_bytes,
                production_tmp_name,
                |_| Ok(()),
            )
            .unwrap_err();
        assert!(matches!(err, ModelError::TooLarge { .. }), "{err:?}");
        assert!(!root.exists());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_failure_before_commit_keeps_existing_and_leaves_no_tmp() {
        let dir = TempDirGuard::new("save-fail");
        let registry = ModelRegistry::with_cache_dir(dir.path().join("root"));
        let state = sample_state();
        registry.save("mlp", "v1", &state).unwrap();
        let mut other = HashMap::new();
        other.insert("x".to_string(), Tensor::new(vec![1.0_f32], &[1]).unwrap());
        let err = registry
            .save_with(
                "mlp",
                "v1",
                &other,
                MAX_MODEL_FILE_BYTES,
                production_tmp_name,
                |_| Err(std::io::Error::other("注入した失敗")),
            )
            .unwrap_err();
        assert!(matches!(err, ModelError::Io(_)), "{err:?}");
        let back = registry.load("mlp", "v1").unwrap();
        assert_eq!(back.len(), state.len());
        for (k, t) in &state {
            assert_eq!(bits(&back[k]), bits(t));
        }
        assert!(tmp_leftovers(&dir.path().join("root/mlp/v1")).is_empty());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_gives_up_after_tmp_name_collisions() {
        let dir = TempDirGuard::new("save-collide");
        let registry = ModelRegistry::with_cache_dir(dir.path().join("root"));
        let state = sample_state();
        registry.save("mlp", "v1", &state).unwrap();
        let vdir = dir.path().join("root/mlp/v1");
        std::fs::write(vdir.join(".collide"), b"keep").unwrap();
        let err = registry
            .save_with(
                "mlp",
                "v1",
                &state,
                MAX_MODEL_FILE_BYTES,
                || ".collide".to_string(),
                |_| Ok(()),
            )
            .unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::AlreadyExists),
            "{err:?}"
        );
        assert_eq!(std::fs::read(vdir.join(".collide")).unwrap(), b"keep");
        assert_eq!(registry.load("mlp", "v1").unwrap().len(), state.len());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_overwrites_existing_atomically() {
        let dir = TempDirGuard::new("save-overwrite");
        let registry = ModelRegistry::with_cache_dir(dir.path().join("root"));
        registry.save("mlp", "v1", &sample_state()).unwrap();
        let mut next = HashMap::new();
        next.insert(
            "w".to_string(),
            Tensor::new(vec![3.0_f32, 4.0], &[2]).unwrap(),
        );
        registry.save("mlp", "v1", &next).unwrap();
        let back = registry.load("mlp", "v1").unwrap();
        assert_eq!(back.len(), 1);
        assert_eq!(bits(&back["w"]), bits(&next["w"]));
        assert_eq!(
            registry.available_models(),
            vec![("mlp".to_string(), vec!["v1".to_string()])]
        );
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn proc_fd_precheck_rejects_missing_proc_and_accepts_real_proc() {
        let dir = TempDirGuard::new("save-noproc");
        assert!(ensure_proc_fd_usable(&dir.path().join("no-such-proc")).is_err());
        assert!(ensure_proc_fd_usable(Path::new("/proc/self/fd")).is_ok());
    }

    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_rejects_symlinked_name_dir_without_writing_outside() {
        let dir = TempDirGuard::new("save-symlink");
        let root = dir.path().join("root");
        let outside = dir.path().join("outside");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::create_dir_all(&outside).unwrap();
        std::os::unix::fs::symlink(&outside, root.join("mlp")).unwrap();
        let registry = ModelRegistry::with_cache_dir(&root);
        let err = registry.save("mlp", "v1", &sample_state()).unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput),
            "{err:?}"
        );
        assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
    }

    /// 検査後(一時ファイル書き込み後・`rename` 前)に `<version>` を退避して
    /// シンボリックリンクへ差し替えても、ルート外へ書かれないこと(codex-review
    /// 指摘・PR #3377 P0。ディレクトリ fd 固定の回帰テスト)。
    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_does_not_write_outside_when_version_dir_swapped_to_symlink() {
        let dir = TempDirGuard::new("save-swap");
        let root = dir.path().join("root");
        let outside = dir.path().join("outside");
        std::fs::create_dir_all(&outside).unwrap();
        let registry = ModelRegistry::with_cache_dir(&root);
        let vdir = root.join("mlp").join("v1");
        let moved = root.join("mlp").join("v1-moved");
        let outside_c = outside.clone();
        let (vdir_c, moved_c) = (vdir.clone(), moved.clone());
        let result = registry.save_with(
            "mlp",
            "v1",
            &sample_state(),
            MAX_MODEL_FILE_BYTES,
            production_tmp_name,
            move |_| {
                std::fs::rename(&vdir_c, &moved_c)?;
                std::os::unix::fs::symlink(&outside_c, &vdir_c)
            },
        );
        assert!(result.is_err(), "差し替えを検出して Err のはず");
        assert_eq!(
            std::fs::read_dir(&outside).unwrap().count(),
            0,
            "ルート外へ書き込んではならない"
        );
    }

    /// 同上。`<name>` 段を差し替えるケース。
    #[cfg(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    ))]
    #[test]
    fn save_does_not_write_outside_when_name_dir_swapped_to_symlink() {
        let dir = TempDirGuard::new("save-swap-name");
        let root = dir.path().join("root");
        let outside = dir.path().join("outside");
        std::fs::create_dir_all(outside.join("v1")).unwrap();
        let registry = ModelRegistry::with_cache_dir(&root);
        let ndir = root.join("mlp");
        let moved = root.join("mlp-moved");
        let (outside_c, ndir_c, moved_c) = (outside.clone(), ndir.clone(), moved.clone());
        let result = registry.save_with(
            "mlp",
            "v1",
            &sample_state(),
            MAX_MODEL_FILE_BYTES,
            production_tmp_name,
            move |_| {
                std::fs::rename(&ndir_c, &moved_c)?;
                std::os::unix::fs::symlink(&outside_c, &ndir_c)
            },
        );
        assert!(result.is_err(), "差し替えを検出して Err のはず");
        assert_eq!(std::fs::read_dir(outside.join("v1")).unwrap().count(), 0);
    }

    #[cfg(not(all(
        target_os = "linux",
        any(target_arch = "x86_64", target_arch = "aarch64")
    )))]
    #[test]
    fn save_is_unsupported_on_non_supported_platform() {
        let registry = ModelRegistry::with_cache_dir("unused-root-for-unsupported-save");
        let err = registry.save("mlp", "v1", &sample_state()).unwrap_err();
        assert!(
            matches!(&err, ModelError::Io(e) if e.kind() == std::io::ErrorKind::Unsupported),
            "{err:?}"
        );
    }

    #[test]
    fn new_error_variants_display_without_path() {
        let e = ModelError::EmptyModel {
            name: "mlp".into(),
            version: "v1".into(),
        };
        let text = format!("{e} {e:?}");
        assert!(text.contains("mlp") && text.contains("v1"));
        assert!(!text.contains('/'));
        use std::error::Error as _;
        assert!(e.source().is_none());
    }
    #[test]
    fn sha256_pin_from_hex_accepts_64_hex_either_case() {
        let lower = "0123456789abcdef".repeat(4);
        let upper = lower.to_uppercase();
        let a = Sha256Pin::from_hex(&lower).unwrap();
        assert_eq!(a, Sha256Pin::from_hex(&upper).unwrap());
        assert_eq!(a.to_string(), lower);
        assert!(format!("{a:?}").contains(&lower));
    }

    #[test]
    fn sha256_pin_from_hex_rejects_malformed() {
        let ok = "a".repeat(64);
        for bad in [
            "a".repeat(63),
            "a".repeat(65),
            String::new(),
            format!("0x{}", "a".repeat(62)),
            format!(" {}", "a".repeat(63)),
            format!("{}g", "a".repeat(63)),
            format!("{}\n", "a".repeat(63)),
        ] {
            assert!(
                matches!(Sha256Pin::from_hex(&bad), Err(ModelError::InvalidPin)),
                "{bad:?}"
            );
        }
        assert!(Sha256Pin::from_hex(&ok).is_ok());
    }

    #[test]
    fn download_error_displays_never_contain_url_material() {
        let secret = "secret-token-xyz";
        let errs = [
            ModelError::InvalidUrl { reason: "r" },
            ModelError::InvalidPin,
            ModelError::Http { status: 404 },
            ModelError::TooManyRedirects { max: 5 },
            ModelError::ContentLengthMismatch {
                expected: 1,
                received: 2,
            },
            ModelError::HashMismatch {
                expected: Sha256Pin([1; 32]),
                actual: Sha256Pin([2; 32]),
            },
            ModelError::Network(std::io::Error::other("net")),
            ModelError::CacheMetadata { reason: "r" },
            ModelError::StorageFull(std::io::Error::other("full")),
            ModelError::PermissionDenied(std::io::Error::other("perm")),
        ];
        for e in errs {
            let text = format!("{e} {e:?}");
            assert!(
                !text.contains(secret) && !text.contains("https://"),
                "{text}"
            );
        }
    }

    #[cfg(unix)]
    fn tmp_root(tag: &str) -> PathBuf {
        let (pid, nanos, counter) = crate::fs_write::name_entropy();
        std::env::temp_dir().join(format!("fandhe-dlreg-{tag}-{pid}-{counter}-{nanos}"))
    }

    #[cfg(unix)]
    #[test]
    fn download_rejects_bad_input_before_touching_fs() {
        let root = tmp_root("reject");
        let registry = ModelRegistry::with_cache_dir(&root);
        for url in [
            "http://example.com/m",
            "https://user:pw@example.com/m",
            "file:///etc/passwd",
            "https://example.com/\n",
            "ftp://example.com/m",
            "https://user@example.com/m",
        ] {
            let err = registry.download(url, "m", "v1").unwrap_err();
            assert!(matches!(err, ModelError::InvalidUrl { .. }), "{err:?}");
            let text = err.to_string();
            assert!(!text.contains("pw@") && !text.contains("etc/passwd"));
        }
        let err = registry
            .download("https://example.com/m", "../x", "v1")
            .unwrap_err();
        assert!(matches!(err, ModelError::InvalidComponent { .. }));
        assert!(!root.exists(), "拒否時にキャッシュルートを作らない");
    }

    /// 接続前に拒否される入力(スキーム・資格情報・pin 形式)ではモックサーバへ一切接続せず、
    /// キャッシュルートも作らない。エラーの `Display` に URL・資格情報を含めない(#3127・A09)。
    #[cfg(unix)]
    #[test]
    fn bad_url_and_malformed_pin_never_connect() {
        use super::test_http_server::{MockReply, MockServer};
        let root = tmp_root("noconnect");
        let registry = ModelRegistry::with_cache_dir(&root);
        let srv = MockServer::start(vec![MockReply::Stall]);
        let p = srv.port();
        for url in [
            format!("http://127.0.0.1:{p}/m"),
            "file:///etc/passwd".to_string(),
            format!("ftp://127.0.0.1:{p}/m"),
            format!("https://u:secretpw@127.0.0.1:{p}/m"),
        ] {
            let err = registry.download(&url, "m", "v1").unwrap_err();
            assert!(matches!(err, ModelError::InvalidUrl { .. }), "{err:?}");
            let text = err.to_string();
            assert!(!text.contains("secretpw") && !text.contains("127.0.0.1"));
        }
        // 不正な pin は `download_with` に到達する前に失敗する。
        for bad in ["", "zz", &"g".repeat(64), &"0".repeat(63)] {
            assert!(matches!(
                Sha256Pin::from_hex(bad),
                Err(ModelError::InvalidPin)
            ));
        }
        assert_eq!(srv.connection_count(), 0, "接続前に拒否される");
        assert!(!root.exists(), "拒否時にキャッシュルートを作らない");
    }

    /// 応答を順に返し、各リクエストの `If-None-Match` を記録するモック転送。
    #[cfg(unix)]
    struct Scripted {
        steps: std::collections::VecDeque<(u16, Option<&'static str>, Vec<u8>)>,
        conditional: Vec<Option<String>>,
    }

    #[cfg(unix)]
    impl Scripted {
        fn new(steps: Vec<(u16, Option<&'static str>, Vec<u8>)>) -> Self {
            Self {
                steps: steps.into(),
                conditional: Vec::new(),
            }
        }
        fn one(body: Vec<u8>) -> Self {
            Self::new(vec![(200, None, body)])
        }
    }

    #[cfg(unix)]
    impl transport::Transport for Scripted {
        fn get(
            &mut self,
            _u: &url::ValidatedUrl,
            _d: std::time::Instant,
            if_none_match: Option<&str>,
        ) -> Result<transport::RawResponse, ModelError> {
            self.conditional.push(if_none_match.map(String::from));
            let (status, etag, body) = self.steps.pop_front().expect("scripted step");
            Ok(transport::RawResponse {
                status,
                location: None,
                content_length: Some(body.len() as u64),
                content_encoding: None,
                etag: etag.map(String::from),
                body: Box::new(std::io::Cursor::new(body)),
            })
        }
    }

    #[cfg(unix)]
    fn dir_names(dir: &Path) -> Vec<String> {
        let mut v: Vec<String> = std::fs::read_dir(dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        v.sort();
        v
    }

    #[cfg(unix)]
    fn dl(
        registry: &ModelRegistry,
        t: &mut Scripted,
        opts: DownloadOptions<'_>,
    ) -> Result<(), ModelError> {
        let u = url::validate_https_url("https://example.com/m").unwrap();
        registry.download_via(t, u, "m", "v1", opts, MAX_MODEL_FILE_BYTES)
    }

    #[cfg(any(
        all(
            target_os = "linux",
            any(target_arch = "x86_64", target_arch = "aarch64")
        ),
        target_os = "macos"
    ))]
    #[test]
    fn download_via_publishes_and_load_reads_back() {
        let root = tmp_root("via");
        let registry = ModelRegistry::with_cache_dir(&root);
        let state = sample_state();
        let bytes =
            crate::interop::safetensors::save_safetensors_f32_to_bytes(&state, None).unwrap();
        dl(
            &registry,
            &mut Scripted::one(bytes.clone()),
            DownloadOptions::default(),
        )
        .unwrap();
        assert_eq!(
            std::fs::read(root.join("m/v1").join(MODEL_FILE_NAME)).unwrap(),
            bytes
        );
        assert_eq!(
            dir_names(&root.join("m/v1")),
            vec!["download.json".to_string(), MODEL_FILE_NAME.to_string()]
        );
        // ダウンロード直後に `load` で読み戻せる(受け入れ条件。F32 の safetensors)。
        let loaded = registry.load("m", "v1").unwrap();
        assert_eq!(loaded.len(), state.len());
        for (k, v) in &state {
            assert_eq!(bits(&loaded[k]), bits(v), "{k}");
        }
        assert_eq!(registry.available_models().len(), 1);
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn download_via_rejects_invalid_safetensors_and_keeps_existing_model() {
        let root = tmp_root("via-invalid");
        let registry = ModelRegistry::with_cache_dir(&root);
        let good =
            crate::interop::safetensors::save_safetensors_f32_to_bytes(&sample_state(), None)
                .unwrap();
        let run = |body: Vec<u8>| {
            dl(
                &registry,
                &mut Scripted::one(body),
                DownloadOptions::default(),
            )
        };
        run(good.clone()).unwrap();
        let mut bad_offsets = good.clone();
        bad_offsets.truncate(bad_offsets.len() - 4);
        for body in [
            b"<html>404 not found</html>".to_vec(),
            vec![0xff; 64],
            bad_offsets,
        ] {
            let e = run(body).unwrap_err();
            assert!(matches!(e, ModelError::Load(_)), "got {e:?}");
            assert_eq!(
                std::fs::read(root.join("m/v1").join(MODEL_FILE_NAME)).unwrap(),
                good,
                "既存の正常なモデルが残る"
            );
            assert_eq!(
                dir_names(&root.join("m/v1")),
                vec!["download.json".to_string(), MODEL_FILE_NAME.to_string()],
                "一時ファイルなし"
            );
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn download_via_accepts_f16_safetensors_but_load_stays_f32_only() {
        let root = tmp_root("via-f16");
        let registry = ModelRegistry::with_cache_dir(&root);
        // facade は `safetensors` に直接依存できないため、F16 の最小ファイルを手組みする
        // (8 バイト LE のヘッダ長 + ヘッダ JSON + データ 4 バイト)。
        let header = br#"{"w":{"dtype":"F16","shape":[2],"data_offsets":[0,4]}}"#;
        let mut bytes = (header.len() as u64).to_le_bytes().to_vec();
        bytes.extend_from_slice(header);
        bytes.extend_from_slice(&[0u8; 4]);
        // 方式 (ii): dtype に依存しないレイアウト検証のため F16 でも公開できる。
        dl(
            &registry,
            &mut Scripted::one(bytes),
            DownloadOptions::default(),
        )
        .unwrap();
        assert!(root.join("m/v1").join(MODEL_FILE_NAME).is_file());
        #[cfg(any(
            all(
                target_os = "linux",
                any(target_arch = "x86_64", target_arch = "aarch64")
            ),
            target_os = "macos"
        ))]
        assert!(matches!(
            registry.load("m", "v1"),
            Err(ModelError::Load(LoadError::UnsupportedDtype { .. }))
        ));
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn download_via_second_fetch_uses_conditional_get_and_reuses_on_304() {
        let root = tmp_root("via-304");
        let registry = ModelRegistry::with_cache_dir(&root);
        let bytes =
            crate::interop::safetensors::save_safetensors_f32_to_bytes(&sample_state(), None)
                .unwrap();
        let mut t = Scripted::new(vec![
            (200, Some("\"v1\""), bytes.clone()),
            (304, None, Vec::new()),
        ]);
        dl(&registry, &mut t, DownloadOptions::default()).unwrap();
        dl(&registry, &mut t, DownloadOptions::default()).unwrap();
        assert_eq!(t.conditional, vec![None, Some("\"v1\"".to_string())]);
        assert_eq!(
            std::fs::read(root.join("m/v1").join(MODEL_FILE_NAME)).unwrap(),
            bytes
        );
        // 保存値が pin と食い違う場合は最初から条件なしで取り直す。
        let other = Sha256Pin::from_hex(&"1".repeat(64)).unwrap();
        let mut t2 = Scripted::new(vec![(200, Some("\"v1\""), bytes.clone())]);
        let e = dl(
            &registry,
            &mut t2,
            DownloadOptions::default().expected_sha256(other),
        )
        .unwrap_err();
        assert!(matches!(e, ModelError::HashMismatch { .. }));
        assert_eq!(t2.conditional, vec![None]);
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn download_permission_denied_is_typed() {
        use std::os::unix::fs::{MetadataExt, PermissionsExt};
        let root = tmp_root("perm");
        let registry = ModelRegistry::with_cache_dir(&root);
        let dir = root.join("m/v1");
        std::fs::create_dir_all(&dir).unwrap();
        // root(作成したディレクトリの所有者が uid 0)はモードを無視するため検証できない。
        // `model.rs` は libc を名指ししない契約のため euid ではなく所有者で判定する。
        if std::fs::metadata(&dir).unwrap().uid() == 0 {
            let _ = std::fs::remove_dir_all(&root);
            return;
        }
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
        let bytes =
            crate::interop::safetensors::save_safetensors_f32_to_bytes(&sample_state(), None)
                .unwrap();
        let r = dl(
            &registry,
            &mut Scripted::one(bytes),
            DownloadOptions::default(),
        );
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
        let e = r.unwrap_err();
        assert!(matches!(e, ModelError::PermissionDenied(_)), "got {e:?}");
        assert!(!e.to_string().contains(root.to_str().unwrap()));
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn map_fs_error_assigns_typed_variants() {
        use std::io::{Error, ErrorKind};
        assert!(matches!(
            map_fs_error(Error::from(ErrorKind::StorageFull)),
            ModelError::StorageFull(_)
        ));
        assert!(matches!(
            map_fs_error(Error::from(ErrorKind::QuotaExceeded)),
            ModelError::StorageFull(_)
        ));
        assert!(matches!(
            map_fs_error(Error::from(ErrorKind::PermissionDenied)),
            ModelError::PermissionDenied(_)
        ));
        assert!(matches!(
            map_fs_error(Error::from(ErrorKind::NotFound)),
            ModelError::Io(_)
        ));
    }

    #[test]
    fn new_error_variants_display_without_path_and_chain_source() {
        use std::error::Error as _;
        let full = ModelError::StorageFull(std::io::Error::other("/secret/path"));
        let perm = ModelError::PermissionDenied(std::io::Error::other("/secret/path"));
        let meta = ModelError::CacheMetadata { reason: "r" };
        for e in [&full, &perm, &meta] {
            assert!(!e.to_string().contains("/secret"));
        }
        assert!(full.source().is_some() && perm.source().is_some());
        assert!(meta.source().is_none());
    }
}