opendal 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (566) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +89 -56
  3. data/Cargo.toml +1 -1
  4. data/DEPENDENCIES.rust.tsv +55 -41
  5. data/LICENSE +201 -0
  6. data/LICENSE-MPL-2.0.txt +373 -0
  7. data/NOTICE +5 -0
  8. data/core/CHANGELOG.md +175 -0
  9. data/core/Cargo.lock +225 -298
  10. data/core/Cargo.toml +100 -94
  11. data/core/DEPENDENCIES.rust.tsv +44 -73
  12. data/core/benches/metadata/README.md +21 -0
  13. data/core/benches/metadata/main.rs +541 -0
  14. data/core/benches/vs_fs/Cargo.toml +1 -1
  15. data/core/benches/vs_s3/Cargo.toml +1 -1
  16. data/core/core/Cargo.toml +7 -2
  17. data/core/core/src/blocking/delete.rs +5 -1
  18. data/core/core/src/blocking/operator.rs +12 -0
  19. data/core/core/src/blocking/read/std_reader.rs +25 -0
  20. data/core/core/src/docs/concepts.rs +70 -94
  21. data/core/core/src/docs/internals/accessor.rs +62 -293
  22. data/core/core/src/docs/internals/layer.rs +43 -59
  23. data/core/core/src/docs/internals/mod.rs +21 -23
  24. data/core/core/src/docs/mod.rs +10 -6
  25. data/core/core/src/docs/performance/concurrent_write.md +92 -55
  26. data/core/core/src/docs/performance/http_optimization.md +120 -104
  27. data/core/core/src/docs/performance/mod.rs +8 -6
  28. data/core/core/src/docs/rfcs/5485_conditional_reader.md +4 -0
  29. data/core/core/src/docs/rfcs/7182_restore_api.md +139 -557
  30. data/core/core/src/docs/rfcs/7945_limited_read.md +210 -0
  31. data/core/core/src/docs/rfcs/7948_s3_provider_presets.md +254 -0
  32. data/core/core/src/docs/rfcs/8109_writer_copy_from.md +330 -0
  33. data/core/core/src/docs/rfcs/8145_conflict_error_kind.md +210 -0
  34. data/core/core/src/docs/rfcs/8147_write_if_not_changed.md +233 -0
  35. data/core/core/src/docs/rfcs/8188_compose_api.md +292 -0
  36. data/core/core/src/docs/rfcs/8194_compact_metadata_and_operation_args.md +444 -0
  37. data/core/core/src/docs/rfcs/README.md +14 -3
  38. data/core/core/src/docs/rfcs/mod.rs +46 -22
  39. data/core/core/src/docs/specs/README.md +34 -0
  40. data/core/core/src/docs/specs/conditional-operations.md +156 -0
  41. data/core/core/src/docs/specs/metadata.md +63 -0
  42. data/core/core/src/docs/specs/mod.rs +26 -0
  43. data/core/core/src/docs/upgrade.md +114 -1
  44. data/core/core/src/layers/capability_override.rs +15 -2
  45. data/core/core/src/layers/complete.rs +347 -43
  46. data/core/core/src/layers/correctness_check.rs +718 -51
  47. data/core/core/src/layers/error_context.rs +76 -2
  48. data/core/core/src/layers/simulate.rs +91 -28
  49. data/core/core/src/lib.rs +1 -0
  50. data/core/core/src/raw/accessor.rs +91 -15
  51. data/core/core/src/raw/enum_utils.rs +7 -0
  52. data/core/core/src/raw/http_util/header.rs +22 -17
  53. data/core/core/src/raw/oio/compose/api.rs +92 -0
  54. data/core/core/src/raw/oio/compose/mod.rs +19 -0
  55. data/core/core/src/raw/oio/copy/api.rs +3 -3
  56. data/core/core/src/raw/oio/copy/block_copy.rs +32 -4
  57. data/core/core/src/raw/oio/copy/multipart_copy.rs +36 -7
  58. data/core/core/src/raw/oio/entry.rs +48 -22
  59. data/core/core/src/raw/oio/list/flat_list.rs +1 -1
  60. data/core/core/src/raw/oio/list/hierarchy_list.rs +3 -2
  61. data/core/core/src/raw/oio/mod.rs +3 -0
  62. data/core/core/src/raw/oio/read/position_read.rs +1 -1
  63. data/core/core/src/raw/oio/write/api.rs +40 -0
  64. data/core/core/src/raw/oio/write/append_write.rs +4 -4
  65. data/core/core/src/raw/oio/write/block_write.rs +2 -2
  66. data/core/core/src/raw/oio/write/multipart_write.rs +181 -48
  67. data/core/core/src/raw/oio/write/position_write.rs +1 -1
  68. data/core/core/src/raw/operation.rs +6 -0
  69. data/core/core/src/raw/ops.rs +1191 -456
  70. data/core/core/src/raw/rps.rs +11 -0
  71. data/core/core/src/raw/time.rs +14 -3
  72. data/core/core/src/services/memory/backend.rs +5 -10
  73. data/core/core/src/services/memory/lister.rs +4 -4
  74. data/core/core/src/services/memory/writer.rs +7 -6
  75. data/core/core/src/types/buffer.rs +102 -12
  76. data/core/core/src/types/bytes_range.rs +20 -1
  77. data/core/core/src/types/capability.rs +65 -0
  78. data/core/core/src/types/compact.rs +329 -0
  79. data/core/core/src/types/compose/composer.rs +183 -0
  80. data/core/core/src/types/compose/input.rs +57 -0
  81. data/core/core/src/types/compose/mod.rs +23 -0
  82. data/core/core/src/types/context/read.rs +16 -11
  83. data/core/core/src/types/context/write.rs +150 -2
  84. data/core/core/src/types/copy.rs +5 -3
  85. data/core/core/src/types/delete/deleter.rs +20 -12
  86. data/core/core/src/types/delete/input.rs +22 -64
  87. data/core/core/src/types/delete/mod.rs +0 -1
  88. data/core/core/src/types/entry.rs +8 -14
  89. data/core/core/src/types/error.rs +28 -11
  90. data/core/core/src/types/metadata.rs +709 -322
  91. data/core/core/src/types/mod.rs +9 -1
  92. data/core/core/src/types/operator/operator.rs +278 -29
  93. data/core/core/src/types/operator/operator_futures.rs +466 -105
  94. data/core/core/src/types/options.rs +631 -158
  95. data/core/core/src/types/read/futures_async_reader.rs +663 -50
  96. data/core/core/src/types/read/reader.rs +23 -5
  97. data/core/core/src/types/write/futures_async_writer.rs +27 -0
  98. data/core/core/src/types/write/writer.rs +47 -5
  99. data/core/http-transports/reqwest/Cargo.toml +1 -1
  100. data/core/layers/async-backtrace/Cargo.toml +2 -2
  101. data/core/layers/async-backtrace/src/lib.rs +21 -2
  102. data/core/layers/await-tree/Cargo.toml +2 -2
  103. data/core/layers/await-tree/src/lib.rs +25 -2
  104. data/core/layers/capability-check/Cargo.toml +2 -2
  105. data/core/layers/capability-check/src/lib.rs +98 -10
  106. data/core/layers/chaos/Cargo.toml +2 -2
  107. data/core/layers/chaos/src/lib.rs +18 -3
  108. data/core/layers/concurrent-limit/Cargo.toml +3 -3
  109. data/core/layers/concurrent-limit/src/lib.rs +53 -13
  110. data/core/layers/dtrace/Cargo.toml +2 -2
  111. data/core/layers/dtrace/src/lib.rs +29 -2
  112. data/core/layers/fastmetrics/Cargo.toml +3 -3
  113. data/core/layers/fastrace/Cargo.toml +2 -2
  114. data/core/layers/fastrace/src/lib.rs +28 -2
  115. data/core/layers/foyer/Cargo.toml +2 -2
  116. data/core/layers/foyer/src/full.rs +16 -19
  117. data/core/layers/foyer/src/lib.rs +67 -14
  118. data/core/layers/hotpath/Cargo.toml +2 -2
  119. data/core/layers/hotpath/src/lib.rs +20 -2
  120. data/core/layers/immutable-index/Cargo.toml +2 -2
  121. data/core/layers/immutable-index/src/lib.rs +48 -24
  122. data/core/layers/logging/Cargo.toml +2 -2
  123. data/core/layers/logging/src/lib.rs +46 -2
  124. data/core/layers/metrics/Cargo.toml +3 -3
  125. data/core/layers/mime-guess/Cargo.toml +2 -2
  126. data/core/layers/mime-guess/src/lib.rs +31 -4
  127. data/core/layers/observe-metrics-common/Cargo.toml +2 -2
  128. data/core/layers/observe-metrics-common/src/lib.rs +55 -2
  129. data/core/layers/otelmetrics/Cargo.toml +3 -3
  130. data/core/layers/oteltrace/Cargo.toml +2 -2
  131. data/core/layers/oteltrace/src/lib.rs +27 -2
  132. data/core/layers/prometheus/Cargo.toml +3 -3
  133. data/core/layers/prometheus-client/Cargo.toml +3 -3
  134. data/core/layers/retry/Cargo.toml +4 -4
  135. data/core/layers/retry/src/lib.rs +76 -19
  136. data/core/layers/route/Cargo.toml +3 -3
  137. data/core/layers/route/src/lib.rs +34 -7
  138. data/core/layers/tail-cut/Cargo.toml +2 -2
  139. data/core/layers/tail-cut/src/lib.rs +28 -2
  140. data/core/layers/throttle/Cargo.toml +2 -2
  141. data/core/layers/throttle/src/lib.rs +19 -2
  142. data/core/layers/timeout/Cargo.toml +3 -3
  143. data/core/layers/timeout/src/lib.rs +38 -13
  144. data/core/layers/tracing/Cargo.toml +2 -2
  145. data/core/layers/tracing/src/lib.rs +24 -3
  146. data/core/services/aliyun-drive/Cargo.toml +2 -2
  147. data/core/services/aliyun-drive/src/backend.rs +34 -24
  148. data/core/services/aliyun-drive/src/core.rs +143 -54
  149. data/core/services/aliyun-drive/src/lister.rs +19 -18
  150. data/core/services/aliyun-drive/src/reader.rs +4 -1
  151. data/core/services/aliyun-drive/src/writer.rs +3 -3
  152. data/core/services/alluxio/Cargo.toml +1 -1
  153. data/core/services/alluxio/src/backend.rs +1 -1
  154. data/core/services/alluxio/src/core.rs +113 -94
  155. data/core/services/alluxio/src/reader.rs +7 -3
  156. data/core/services/alluxio/src/writer.rs +2 -2
  157. data/core/services/azblob/Cargo.toml +2 -2
  158. data/core/services/azblob/src/backend.rs +17 -9
  159. data/core/services/azblob/src/copier.rs +41 -15
  160. data/core/services/azblob/src/core.rs +372 -135
  161. data/core/services/azblob/src/deleter.rs +41 -18
  162. data/core/services/azblob/src/lister.rs +16 -10
  163. data/core/services/azblob/src/reader.rs +4 -1
  164. data/core/services/azblob/src/writer.rs +48 -15
  165. data/core/services/azdls/Cargo.toml +3 -3
  166. data/core/services/azdls/src/backend.rs +45 -15
  167. data/core/services/azdls/src/config.rs +4 -1
  168. data/core/services/azdls/src/core.rs +276 -87
  169. data/core/services/azdls/src/deleter.rs +13 -4
  170. data/core/services/azdls/src/lister.rs +18 -12
  171. data/core/services/azdls/src/reader.rs +6 -2
  172. data/core/services/azdls/src/writer.rs +44 -23
  173. data/core/services/azfile/Cargo.toml +2 -2
  174. data/core/services/azfile/src/backend.rs +24 -8
  175. data/core/services/azfile/src/core.rs +195 -77
  176. data/core/services/azfile/src/deleter.rs +8 -1
  177. data/core/services/azfile/src/lister.rs +20 -14
  178. data/core/services/azfile/src/reader.rs +5 -2
  179. data/core/services/azfile/src/writer.rs +27 -15
  180. data/core/services/azure-common/Cargo.toml +1 -1
  181. data/core/services/b2/Cargo.toml +2 -2
  182. data/core/services/b2/src/backend.rs +30 -11
  183. data/core/services/b2/src/core.rs +134 -120
  184. data/core/services/b2/src/deleter.rs +1 -1
  185. data/core/services/b2/src/lister.rs +5 -2
  186. data/core/services/b2/src/reader.rs +5 -2
  187. data/core/services/b2/src/writer.rs +29 -12
  188. data/core/services/cacache/Cargo.toml +1 -1
  189. data/core/services/cacache/src/backend.rs +4 -5
  190. data/core/services/cacache/src/reader.rs +4 -1
  191. data/core/services/cacache/src/writer.rs +6 -2
  192. data/core/services/cloudflare-kv/Cargo.toml +1 -1
  193. data/core/services/cloudflare-kv/src/backend.rs +26 -15
  194. data/core/services/cloudflare-kv/src/config.rs +13 -2
  195. data/core/services/cloudflare-kv/src/core.rs +62 -56
  196. data/core/services/cloudflare-kv/src/deleter.rs +12 -3
  197. data/core/services/cloudflare-kv/src/lister.rs +66 -17
  198. data/core/services/cloudflare-kv/src/reader.rs +14 -9
  199. data/core/services/cloudflare-kv/src/writer.rs +10 -7
  200. data/core/services/compfs/Cargo.toml +1 -1
  201. data/core/services/compfs/src/backend.rs +11 -7
  202. data/core/services/compfs/src/lister.rs +7 -7
  203. data/core/services/compfs/src/writer.rs +1 -1
  204. data/core/services/cos/Cargo.toml +1 -1
  205. data/core/services/cos/src/backend.rs +129 -16
  206. data/core/services/cos/src/core.rs +198 -80
  207. data/core/services/cos/src/deleter.rs +4 -1
  208. data/core/services/cos/src/lister.rs +42 -27
  209. data/core/services/cos/src/reader.rs +6 -2
  210. data/core/services/cos/src/writer.rs +45 -21
  211. data/core/services/d1/Cargo.toml +1 -1
  212. data/core/services/d1/src/backend.rs +6 -5
  213. data/core/services/d1/src/core.rs +79 -63
  214. data/core/services/d1/src/reader.rs +4 -1
  215. data/core/services/d1/src/writer.rs +6 -2
  216. data/core/services/dashmap/Cargo.toml +1 -1
  217. data/core/services/dashmap/src/backend.rs +2 -2
  218. data/core/services/dashmap/src/reader.rs +4 -1
  219. data/core/services/dashmap/src/writer.rs +11 -8
  220. data/core/services/dbfs/Cargo.toml +1 -1
  221. data/core/services/dbfs/src/backend.rs +21 -15
  222. data/core/services/dbfs/src/core.rs +65 -60
  223. data/core/services/dbfs/src/deleter.rs +4 -1
  224. data/core/services/dbfs/src/lister.rs +10 -10
  225. data/core/services/dbfs/src/writer.rs +6 -3
  226. data/core/services/dropbox/Cargo.toml +2 -2
  227. data/core/services/dropbox/src/backend.rs +30 -23
  228. data/core/services/dropbox/src/core.rs +65 -63
  229. data/core/services/dropbox/src/deleter.rs +1 -1
  230. data/core/services/dropbox/src/lister.rs +17 -8
  231. data/core/services/dropbox/src/reader.rs +4 -1
  232. data/core/services/dropbox/src/writer.rs +13 -10
  233. data/core/services/etcd/Cargo.toml +1 -1
  234. data/core/services/etcd/src/backend.rs +8 -5
  235. data/core/services/etcd/src/core.rs +5 -11
  236. data/core/services/etcd/src/lister.rs +6 -1
  237. data/core/services/etcd/src/reader.rs +4 -1
  238. data/core/services/etcd/src/writer.rs +6 -3
  239. data/core/services/foundationdb/Cargo.toml +1 -1
  240. data/core/services/foundationdb/src/backend.rs +6 -5
  241. data/core/services/foundationdb/src/reader.rs +4 -1
  242. data/core/services/foundationdb/src/writer.rs +6 -2
  243. data/core/services/foyer/Cargo.toml +3 -3
  244. data/core/services/foyer/src/backend.rs +6 -5
  245. data/core/services/foyer/src/reader.rs +4 -1
  246. data/core/services/foyer/src/writer.rs +6 -2
  247. data/core/services/fs/Cargo.toml +1 -1
  248. data/core/services/fs/src/backend.rs +4 -3
  249. data/core/services/fs/src/core.rs +44 -26
  250. data/core/services/fs/src/lister.rs +9 -6
  251. data/core/services/fs/src/writer.rs +20 -13
  252. data/core/services/ftp/Cargo.toml +4 -6
  253. data/core/services/ftp/src/backend.rs +9 -5
  254. data/core/services/ftp/src/core.rs +9 -7
  255. data/core/services/ftp/src/lister.rs +7 -8
  256. data/core/services/ftp/src/reader.rs +3 -3
  257. data/core/services/ftp/src/writer.rs +3 -3
  258. data/core/services/gcs/Cargo.toml +4 -2
  259. data/core/services/gcs/src/backend.rs +53 -7
  260. data/core/services/gcs/src/composer.rs +705 -0
  261. data/core/services/gcs/src/copier.rs +14 -4
  262. data/core/services/gcs/src/core.rs +828 -124
  263. data/core/services/gcs/src/deleter.rs +33 -18
  264. data/core/services/gcs/src/lib.rs +1 -0
  265. data/core/services/gcs/src/lister.rs +21 -12
  266. data/core/services/gcs/src/reader.rs +12 -5
  267. data/core/services/gcs/src/writer.rs +632 -10
  268. data/core/services/gcs-grpc/Cargo.toml +58 -0
  269. data/core/services/gcs-grpc/README.md +19 -0
  270. data/core/services/gcs-grpc/proto/README.md +23 -0
  271. data/core/services/gcs-grpc/proto/google/storage/v2/storage.proto +171 -0
  272. data/core/services/gcs-grpc/proto/upstream-descriptor.bin +0 -0
  273. data/core/services/gcs-grpc/src/backend.rs +449 -0
  274. data/core/services/gcs-grpc/src/config.rs +116 -0
  275. data/core/services/gcs-grpc/src/copier.rs +98 -0
  276. data/core/services/gcs-grpc/src/core.rs +298 -0
  277. data/core/services/gcs-grpc/src/deleter.rs +65 -0
  278. data/core/services/gcs-grpc/src/docs.md +19 -0
  279. data/core/services/gcs-grpc/src/generated/google.storage.v2.rs +541 -0
  280. data/core/services/gcs-grpc/src/generated/mod.rs +24 -0
  281. data/core/services/gcs-grpc/src/lib.rs +43 -0
  282. data/core/services/gcs-grpc/src/lister.rs +125 -0
  283. data/core/services/gcs-grpc/src/reader.rs +140 -0
  284. data/core/services/gcs-grpc/src/writer.rs +780 -0
  285. data/core/services/gdrive/Cargo.toml +2 -2
  286. data/core/services/gdrive/src/backend.rs +123 -69
  287. data/core/services/gdrive/src/core.rs +87 -69
  288. data/core/services/gdrive/src/deleter.rs +8 -2
  289. data/core/services/gdrive/src/lister.rs +38 -26
  290. data/core/services/gdrive/src/path_index.rs +2 -2
  291. data/core/services/gdrive/src/reader.rs +9 -3
  292. data/core/services/gdrive/src/writer.rs +13 -7
  293. data/core/services/ghac/Cargo.toml +2 -2
  294. data/core/services/ghac/src/backend.rs +5 -2
  295. data/core/services/ghac/src/core.rs +59 -37
  296. data/core/services/ghac/src/reader.rs +5 -2
  297. data/core/services/ghac/src/writer.rs +16 -6
  298. data/core/services/github/Cargo.toml +1 -1
  299. data/core/services/github/src/backend.rs +16 -8
  300. data/core/services/github/src/core.rs +88 -76
  301. data/core/services/github/src/lister.rs +17 -15
  302. data/core/services/github/src/reader.rs +5 -2
  303. data/core/services/github/src/writer.rs +12 -6
  304. data/core/services/goosefs/Cargo.toml +2 -2
  305. data/core/services/goosefs/src/backend.rs +2 -2
  306. data/core/services/goosefs/src/core.rs +291 -208
  307. data/core/services/goosefs/src/docs.md +3 -0
  308. data/core/services/goosefs/src/lister.rs +2 -3
  309. data/core/services/goosefs/src/reader.rs +7 -3
  310. data/core/services/goosefs/src/writer.rs +21 -10
  311. data/core/services/goosefs/tests/safe_conditional_put.rs +37 -0
  312. data/core/services/gridfs/Cargo.toml +2 -2
  313. data/core/services/gridfs/src/backend.rs +12 -8
  314. data/core/services/gridfs/src/core.rs +1 -1
  315. data/core/services/gridfs/src/reader.rs +4 -1
  316. data/core/services/gridfs/src/writer.rs +6 -2
  317. data/core/services/hdfs/Cargo.toml +1 -1
  318. data/core/services/hdfs/src/backend.rs +1 -1
  319. data/core/services/hdfs/src/core.rs +7 -4
  320. data/core/services/hdfs/src/lister.rs +7 -9
  321. data/core/services/hdfs/src/writer.rs +5 -1
  322. data/core/services/hdfs-native/Cargo.toml +1 -1
  323. data/core/services/hdfs-native/src/backend.rs +1 -1
  324. data/core/services/hdfs-native/src/core.rs +44 -50
  325. data/core/services/hdfs-native/src/lister.rs +8 -10
  326. data/core/services/hdfs-native/src/writer.rs +5 -1
  327. data/core/services/hf/Cargo.toml +4 -3
  328. data/core/services/hf/src/backend.rs +4 -3
  329. data/core/services/hf/src/core.rs +907 -153
  330. data/core/services/hf/src/lister.rs +16 -6
  331. data/core/services/hf/src/reader.rs +377 -57
  332. data/core/services/hf/src/writer.rs +6 -2
  333. data/core/services/http/Cargo.toml +3 -3
  334. data/core/services/http/src/backend.rs +8 -6
  335. data/core/services/http/src/core.rs +57 -35
  336. data/core/services/http/src/reader.rs +5 -2
  337. data/core/services/ipfs/Cargo.toml +1 -1
  338. data/core/services/ipfs/src/backend.rs +20 -9
  339. data/core/services/ipfs/src/core.rs +43 -34
  340. data/core/services/ipfs/src/reader.rs +5 -2
  341. data/core/services/ipmfs/Cargo.toml +1 -1
  342. data/core/services/ipmfs/src/backend.rs +17 -8
  343. data/core/services/ipmfs/src/core.rs +66 -60
  344. data/core/services/ipmfs/src/deleter.rs +5 -2
  345. data/core/services/ipmfs/src/lister.rs +15 -12
  346. data/core/services/ipmfs/src/reader.rs +5 -2
  347. data/core/services/ipmfs/src/writer.rs +6 -3
  348. data/core/services/koofr/Cargo.toml +2 -2
  349. data/core/services/koofr/src/backend.rs +46 -17
  350. data/core/services/koofr/src/core.rs +64 -48
  351. data/core/services/koofr/src/deleter.rs +4 -1
  352. data/core/services/koofr/src/lister.rs +11 -10
  353. data/core/services/koofr/src/reader.rs +5 -2
  354. data/core/services/koofr/src/writer.rs +13 -7
  355. data/core/services/lakefs/Cargo.toml +1 -1
  356. data/core/services/lakefs/src/backend.rs +25 -8
  357. data/core/services/lakefs/src/core.rs +79 -73
  358. data/core/services/lakefs/src/deleter.rs +4 -1
  359. data/core/services/lakefs/src/lister.rs +17 -14
  360. data/core/services/lakefs/src/reader.rs +5 -2
  361. data/core/services/lakefs/src/writer.rs +11 -10
  362. data/core/services/memcached/Cargo.toml +1 -1
  363. data/core/services/memcached/src/backend.rs +57 -7
  364. data/core/services/memcached/src/config.rs +13 -2
  365. data/core/services/memcached/src/core.rs +11 -1
  366. data/core/services/memcached/src/reader.rs +4 -1
  367. data/core/services/memcached/src/writer.rs +6 -2
  368. data/core/services/mini_moka/Cargo.toml +1 -1
  369. data/core/services/mini_moka/src/backend.rs +8 -8
  370. data/core/services/mini_moka/src/reader.rs +4 -1
  371. data/core/services/mini_moka/src/writer.rs +11 -7
  372. data/core/services/moka/Cargo.toml +1 -1
  373. data/core/services/moka/src/backend.rs +7 -7
  374. data/core/services/moka/src/reader.rs +4 -1
  375. data/core/services/moka/src/writer.rs +10 -6
  376. data/core/services/mongodb/Cargo.toml +2 -2
  377. data/core/services/mongodb/src/backend.rs +7 -6
  378. data/core/services/mongodb/src/core.rs +1 -1
  379. data/core/services/mongodb/src/reader.rs +4 -1
  380. data/core/services/mongodb/src/writer.rs +6 -2
  381. data/core/services/monoiofs/Cargo.toml +2 -2
  382. data/core/services/monoiofs/src/backend.rs +16 -10
  383. data/core/services/monoiofs/src/core.rs +8 -9
  384. data/core/services/monoiofs/src/reader.rs +8 -10
  385. data/core/services/monoiofs/src/writer.rs +19 -24
  386. data/core/services/mysql/Cargo.toml +2 -2
  387. data/core/services/mysql/src/backend.rs +7 -6
  388. data/core/services/mysql/src/core.rs +1 -1
  389. data/core/services/mysql/src/lister.rs +7 -3
  390. data/core/services/mysql/src/reader.rs +4 -1
  391. data/core/services/mysql/src/writer.rs +6 -2
  392. data/core/services/obs/Cargo.toml +1 -1
  393. data/core/services/obs/src/backend.rs +29 -11
  394. data/core/services/obs/src/core.rs +81 -82
  395. data/core/services/obs/src/deleter.rs +4 -1
  396. data/core/services/obs/src/lister.rs +12 -5
  397. data/core/services/obs/src/reader.rs +5 -2
  398. data/core/services/obs/src/writer.rs +40 -19
  399. data/core/services/onedrive/Cargo.toml +2 -2
  400. data/core/services/onedrive/src/backend.rs +30 -21
  401. data/core/services/onedrive/src/config.rs +2 -2
  402. data/core/services/onedrive/src/core.rs +191 -90
  403. data/core/services/onedrive/src/deleter.rs +5 -2
  404. data/core/services/onedrive/src/graph_model.rs +10 -51
  405. data/core/services/onedrive/src/lister.rs +60 -24
  406. data/core/services/onedrive/src/reader.rs +17 -2
  407. data/core/services/onedrive/src/writer.rs +26 -15
  408. data/core/services/opfs/Cargo.toml +1 -1
  409. data/core/services/opfs/src/backend.rs +6 -13
  410. data/core/services/opfs/src/core.rs +33 -37
  411. data/core/services/opfs/src/lister.rs +4 -4
  412. data/core/services/opfs/src/writer.rs +1 -3
  413. data/core/services/oss/Cargo.toml +1 -1
  414. data/core/services/oss/src/backend.rs +40 -8
  415. data/core/services/oss/src/core.rs +192 -92
  416. data/core/services/oss/src/deleter.rs +16 -6
  417. data/core/services/oss/src/lister.rs +43 -27
  418. data/core/services/oss/src/reader.rs +6 -2
  419. data/core/services/oss/src/writer.rs +40 -22
  420. data/core/services/pcloud/Cargo.toml +1 -1
  421. data/core/services/pcloud/src/backend.rs +40 -6
  422. data/core/services/pcloud/src/core.rs +80 -66
  423. data/core/services/pcloud/src/deleter.rs +8 -1
  424. data/core/services/pcloud/src/lister.rs +4 -1
  425. data/core/services/pcloud/src/reader.rs +5 -2
  426. data/core/services/pcloud/src/writer.rs +6 -3
  427. data/core/services/persy/Cargo.toml +1 -1
  428. data/core/services/persy/src/backend.rs +6 -5
  429. data/core/services/persy/src/core.rs +60 -15
  430. data/core/services/persy/src/reader.rs +4 -1
  431. data/core/services/persy/src/writer.rs +6 -2
  432. data/core/services/postgresql/Cargo.toml +2 -2
  433. data/core/services/postgresql/src/backend.rs +7 -6
  434. data/core/services/postgresql/src/core.rs +1 -1
  435. data/core/services/postgresql/src/reader.rs +4 -1
  436. data/core/services/postgresql/src/writer.rs +6 -2
  437. data/core/services/redb/Cargo.toml +1 -1
  438. data/core/services/redb/src/backend.rs +6 -5
  439. data/core/services/redb/src/reader.rs +4 -1
  440. data/core/services/redb/src/writer.rs +6 -2
  441. data/core/services/redis/Cargo.toml +1 -1
  442. data/core/services/redis/src/backend.rs +18 -8
  443. data/core/services/redis/src/config.rs +13 -2
  444. data/core/services/redis/src/reader.rs +8 -4
  445. data/core/services/redis/src/writer.rs +6 -2
  446. data/core/services/rocksdb/Cargo.toml +1 -1
  447. data/core/services/rocksdb/src/backend.rs +6 -5
  448. data/core/services/rocksdb/src/lister.rs +6 -5
  449. data/core/services/rocksdb/src/reader.rs +4 -1
  450. data/core/services/rocksdb/src/writer.rs +6 -2
  451. data/core/services/s3/Cargo.toml +5 -4
  452. data/core/services/s3/src/backend.rs +995 -39
  453. data/core/services/s3/src/compatible_services.md +57 -0
  454. data/core/services/s3/src/config.rs +16 -0
  455. data/core/services/s3/src/copier.rs +190 -55
  456. data/core/services/s3/src/core.rs +299 -190
  457. data/core/services/s3/src/deleter.rs +26 -7
  458. data/core/services/s3/src/docs.md +57 -0
  459. data/core/services/s3/src/lister.rs +55 -36
  460. data/core/services/s3/src/reader.rs +22 -2
  461. data/core/services/s3/src/writer.rs +154 -32
  462. data/core/services/seafile/Cargo.toml +2 -2
  463. data/core/services/seafile/src/backend.rs +3 -3
  464. data/core/services/seafile/src/core.rs +105 -79
  465. data/core/services/seafile/src/lister.rs +11 -8
  466. data/core/services/seafile/src/reader.rs +5 -2
  467. data/core/services/seafile/src/writer.rs +6 -3
  468. data/core/services/sftp/Cargo.toml +1 -1
  469. data/core/services/sftp/src/backend.rs +19 -4
  470. data/core/services/sftp/src/core.rs +51 -46
  471. data/core/services/sftp/src/lister.rs +4 -4
  472. data/core/services/sftp/src/writer.rs +1 -1
  473. data/core/services/sled/Cargo.toml +1 -1
  474. data/core/services/sled/src/backend.rs +6 -5
  475. data/core/services/sled/src/core.rs +28 -6
  476. data/core/services/sled/src/lister.rs +6 -5
  477. data/core/services/sled/src/reader.rs +4 -1
  478. data/core/services/sled/src/writer.rs +6 -2
  479. data/core/services/sqlite/Cargo.toml +2 -2
  480. data/core/services/sqlite/src/backend.rs +12 -7
  481. data/core/services/sqlite/src/core.rs +3 -3
  482. data/core/services/sqlite/src/reader.rs +4 -1
  483. data/core/services/sqlite/src/writer.rs +7 -3
  484. data/core/services/surrealdb/Cargo.toml +2 -2
  485. data/core/services/surrealdb/src/backend.rs +7 -6
  486. data/core/services/surrealdb/src/core.rs +1 -1
  487. data/core/services/surrealdb/src/reader.rs +4 -1
  488. data/core/services/surrealdb/src/writer.rs +6 -2
  489. data/core/services/swift/Cargo.toml +1 -1
  490. data/core/services/swift/src/backend.rs +38 -8
  491. data/core/services/swift/src/core.rs +63 -67
  492. data/core/services/swift/src/deleter.rs +8 -2
  493. data/core/services/swift/src/lister.rs +11 -8
  494. data/core/services/swift/src/reader.rs +5 -2
  495. data/core/services/swift/src/writer.rs +17 -8
  496. data/core/services/tikv/Cargo.toml +2 -2
  497. data/core/services/tikv/src/backend.rs +7 -6
  498. data/core/services/tikv/src/core.rs +31 -14
  499. data/core/services/tikv/src/reader.rs +4 -1
  500. data/core/services/tikv/src/writer.rs +6 -2
  501. data/core/services/tos/Cargo.toml +2 -2
  502. data/core/services/tos/src/backend.rs +45 -21
  503. data/core/services/tos/src/copier.rs +44 -17
  504. data/core/services/tos/src/core.rs +147 -117
  505. data/core/services/tos/src/deleter.rs +9 -3
  506. data/core/services/tos/src/docs.md +1 -1
  507. data/core/services/tos/src/lister.rs +43 -28
  508. data/core/services/tos/src/reader.rs +5 -2
  509. data/core/services/tos/src/writer.rs +37 -16
  510. data/core/services/upyun/Cargo.toml +1 -1
  511. data/core/services/upyun/src/backend.rs +30 -7
  512. data/core/services/upyun/src/core.rs +122 -87
  513. data/core/services/upyun/src/deleter.rs +4 -1
  514. data/core/services/upyun/src/lister.rs +10 -10
  515. data/core/services/upyun/src/reader.rs +5 -2
  516. data/core/services/upyun/src/writer.rs +19 -7
  517. data/core/services/vercel-artifacts/Cargo.toml +1 -1
  518. data/core/services/vercel-artifacts/src/backend.rs +6 -3
  519. data/core/services/vercel-artifacts/src/core.rs +32 -27
  520. data/core/services/vercel-artifacts/src/reader.rs +5 -2
  521. data/core/services/vercel-artifacts/src/writer.rs +6 -3
  522. data/core/services/vercel-blob/Cargo.toml +1 -1
  523. data/core/services/vercel-blob/src/backend.rs +23 -6
  524. data/core/services/vercel-blob/src/core.rs +92 -81
  525. data/core/services/vercel-blob/src/lister.rs +12 -3
  526. data/core/services/vercel-blob/src/reader.rs +5 -2
  527. data/core/services/vercel-blob/src/writer.rs +19 -7
  528. data/core/services/webdav/Cargo.toml +2 -2
  529. data/core/services/webdav/src/backend.rs +25 -5
  530. data/core/services/webdav/src/core.rs +116 -59
  531. data/core/services/webdav/src/deleter.rs +4 -1
  532. data/core/services/webdav/src/lister.rs +4 -1
  533. data/core/services/webdav/src/reader.rs +6 -2
  534. data/core/services/webdav/src/writer.rs +17 -7
  535. data/core/services/webhdfs/Cargo.toml +2 -2
  536. data/core/services/webhdfs/src/backend.rs +24 -11
  537. data/core/services/webhdfs/src/core.rs +99 -98
  538. data/core/services/webhdfs/src/deleter.rs +5 -2
  539. data/core/services/webhdfs/src/lister.rs +21 -9
  540. data/core/services/webhdfs/src/reader.rs +5 -2
  541. data/core/services/webhdfs/src/writer.rs +49 -14
  542. data/core/services/yandex-disk/Cargo.toml +1 -1
  543. data/core/services/yandex-disk/src/backend.rs +28 -6
  544. data/core/services/yandex-disk/src/core.rs +97 -79
  545. data/core/services/yandex-disk/src/deleter.rs +4 -1
  546. data/core/services/yandex-disk/src/lister.rs +25 -9
  547. data/core/services/yandex-disk/src/reader.rs +5 -2
  548. data/core/services/yandex-disk/src/writer.rs +6 -3
  549. data/core/src/lib.rs +5 -0
  550. data/core/testkit/Cargo.toml +4 -4
  551. data/core/testkit/src/utils.rs +53 -8
  552. data/core/tests/behavior/async_compose.rs +297 -0
  553. data/core/tests/behavior/async_copy.rs +162 -1
  554. data/core/tests/behavior/async_delete.rs +364 -3
  555. data/core/tests/behavior/async_read.rs +86 -0
  556. data/core/tests/behavior/async_restore.rs +162 -0
  557. data/core/tests/behavior/async_stat.rs +90 -0
  558. data/core/tests/behavior/async_write.rs +330 -2
  559. data/core/tests/behavior/main.rs +4 -0
  560. data/core/upgrade.md +114 -1
  561. data/src/capability.rs +0 -1
  562. data/src/io.rs +0 -1
  563. data/src/lister.rs +0 -1
  564. data/src/metadata.rs +0 -1
  565. data/src/operator_info.rs +0 -1
  566. metadata +44 -2
@@ -15,13 +15,18 @@
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
17
 
18
+ use asyncband::mutex::Mutex;
19
+ use asyncband::once::OnceCell;
18
20
  use bytes::Buf;
19
21
  use bytes::Bytes;
20
22
  use http::Request;
21
23
  use http::Response;
22
24
  use http::header;
25
+ use log::debug;
23
26
  use serde::{Deserialize, Serialize};
24
27
  use std::fmt::Debug;
28
+ use std::sync::Arc;
29
+ use std::time::{SystemTime, UNIX_EPOCH};
25
30
 
26
31
  use xet::xet_session::{XetDownloadStreamGroup, XetSession, XetSessionBuilder, XetUploadCommit};
27
32
 
@@ -154,12 +159,74 @@ pub(super) struct MixedCommitPayload {
154
159
 
155
160
  // API response types
156
161
 
157
- #[derive(Deserialize)]
162
+ #[derive(Deserialize, Clone)]
158
163
  pub(super) struct XetFileResponse {
159
164
  pub hash: String,
160
165
  pub size: u64,
161
166
  }
162
167
 
168
+ #[derive(Deserialize)]
169
+ pub(super) struct RepoInfoResponse {
170
+ pub id: String,
171
+ }
172
+
173
+ /// Response shape of HF's `xet-{read,write}-token` endpoint. Matches
174
+ /// `CasJWTInfo` in the vendored `xet` crate (`xet_client::hub_client::types`)
175
+ /// — the same three fields regardless of read or write token.
176
+ #[derive(Deserialize)]
177
+ #[serde(rename_all = "camelCase")]
178
+ struct XetTokenResponse {
179
+ cas_url: String,
180
+ exp: u64,
181
+ access_token: String,
182
+ }
183
+
184
+ /// A cached CAS access token, so a new [`XetDownloadStreamGroup`] doesn't
185
+ /// have to fetch its own on every creation.
186
+ #[derive(Clone)]
187
+ struct XetToken {
188
+ cas_url: String,
189
+ access_token: String,
190
+ /// Unix timestamp (seconds) at which HF says this token actually
191
+ /// expires.
192
+ expires_at: u64,
193
+ }
194
+
195
+ /// How much earlier than a token's real expiry to refresh it ourselves.
196
+ /// Larger than the `xet` crate's own internal 30s buffer
197
+ /// (`xet_client::cas_client::auth::REFRESH_BUFFER_SEC`) so a group we just
198
+ /// built has headroom left, instead of immediately tripping that buffer and
199
+ /// refreshing again itself via its own unmocked, uncounted HTTP client.
200
+ const XET_TOKEN_REFRESH_BUFFER_SECS: u64 = 120;
201
+
202
+ /// Which CAS token scope to fetch/cache -- reads and writes are distinct HF
203
+ /// API token scopes, each with their own cache slot on [`HfCore`].
204
+ #[derive(Clone, Copy, PartialEq, Eq)]
205
+ pub(super) enum XetTokenScope {
206
+ Read,
207
+ Write,
208
+ }
209
+
210
+ impl XetTokenScope {
211
+ fn as_str(self) -> &'static str {
212
+ match self {
213
+ Self::Read => "read",
214
+ Self::Write => "write",
215
+ }
216
+ }
217
+
218
+ /// The `Operation` a fetch for this scope's token should be tagged
219
+ /// with, so a token-less writer hits `request()`'s local
220
+ /// `PermissionDenied` fast-path instead of an unauthenticated network
221
+ /// call.
222
+ fn operation(self) -> Operation {
223
+ match self {
224
+ Self::Read => Operation::Read,
225
+ Self::Write => Operation::Write,
226
+ }
227
+ }
228
+ }
229
+
163
230
  #[derive(serde::Deserialize, Debug)]
164
231
  pub(super) struct CommitResponse {
165
232
  #[allow(dead_code)]
@@ -200,23 +267,26 @@ impl PathInfo {
200
267
 
201
268
  pub fn metadata(&self) -> Result<Metadata> {
202
269
  let mode = self.entry_mode();
203
- let mut meta = Metadata::new(mode);
270
+ let mut meta = match mode {
271
+ EntryMode::FILE => MetadataBuilder::file(self.size),
272
+ EntryMode::DIR => MetadataBuilder::dir(),
273
+ EntryMode::Unknown => MetadataBuilder::unknown(),
274
+ };
204
275
 
205
276
  if let Some(commit_info) = self.last_commit.as_ref() {
206
- meta.set_last_modified(commit_info.date.parse::<Timestamp>()?);
277
+ meta.last_modified(commit_info.date.parse::<Timestamp>()?);
207
278
  }
208
279
 
209
280
  if mode == EntryMode::FILE {
210
- meta.set_content_length(self.size);
211
281
  // For buckets, oid may be None; for regular repos, prefer lfs.oid then oid
212
282
  if let Some(lfs) = &self.lfs {
213
- meta.set_etag(&lfs.oid);
283
+ meta.etag(&lfs.oid);
214
284
  } else if let Some(oid) = &self.oid {
215
- meta.set_etag(oid);
285
+ meta.etag(oid);
216
286
  }
217
287
  }
218
288
 
219
- Ok(meta)
289
+ Ok(meta.build())
220
290
  }
221
291
  }
222
292
 
@@ -244,6 +314,14 @@ pub struct HfCore {
244
314
  pub endpoint: String,
245
315
  pub xet_session: XetSession,
246
316
  pub download_mode: HfDownloadMode,
317
+ canonical_repo: OnceCell<HfRepo>,
318
+ /// Cached CAS read token, shared by every `XetDownloadStreamGroup` this
319
+ /// core creates, so at most one `xet-read-token` request happens per
320
+ /// token lifetime instead of one per group (one per file read).
321
+ xet_read_token: Arc<Mutex<Option<XetToken>>>,
322
+ /// Same idea for `XetUploadCommit`'s `xet-write-token`. Separate slot:
323
+ /// read and write are distinct HF API token scopes.
324
+ xet_write_token: Arc<Mutex<Option<XetToken>>>,
247
325
  }
248
326
 
249
327
  impl Debug for HfCore {
@@ -277,6 +355,9 @@ impl HfCore {
277
355
  endpoint,
278
356
  xet_session,
279
357
  download_mode,
358
+ canonical_repo: OnceCell::new(),
359
+ xet_read_token: Arc::new(Mutex::new(None)),
360
+ xet_write_token: Arc::new(Mutex::new(None)),
280
361
  }
281
362
  }
282
363
 
@@ -315,18 +396,24 @@ impl HfCore {
315
396
  headers
316
397
  }
317
398
 
318
- /// Create a new XET upload commit with token refresh configured.
319
- ///
320
- /// Each call creates a fresh upload commit from the shared XET session.
321
- pub(super) async fn xet_upload_commit(&self) -> Result<XetUploadCommit> {
322
- let refresh_url = self.repo.xet_token_url(&self.endpoint, "write");
323
- let refresh_headers = self.xet_token_refresh_headers();
399
+ /// Create a new XET upload commit, seeded with a cached CAS write token
400
+ /// so it doesn't have to fetch its own. Still creates a fresh commit
401
+ /// each call -- only the token (and its `/api` request) is reused; see
402
+ /// [`Self::xet_download_group`] for why the object itself isn't cached.
403
+ pub(super) async fn xet_upload_commit(
404
+ &self,
405
+ ctx: &OperationContext,
406
+ ) -> Result<XetUploadCommit> {
407
+ let (token, refresh_url, refresh_headers) =
408
+ self.xet_auth(ctx, XetTokenScope::Write).await?;
324
409
  self.xet_session
325
410
  .new_upload_commit()
326
411
  .map_err(|err| {
327
412
  Error::new(ErrorKind::Unexpected, "failed to create xet upload commit")
328
413
  .set_source(err)
329
414
  })?
415
+ .with_endpoint(token.cas_url)
416
+ .with_token_info(token.access_token, token.expires_at)
330
417
  .with_token_refresh_url(refresh_url, refresh_headers)
331
418
  .build()
332
419
  .await
@@ -336,12 +423,97 @@ impl HfCore {
336
423
  })
337
424
  }
338
425
 
339
- /// Create a new XET download stream group with token refresh configured.
340
- ///
341
- /// Each call creates a fresh download group from the shared XET session.
342
- pub(super) async fn xet_download_group(&self) -> Result<XetDownloadStreamGroup> {
343
- let refresh_url = self.repo.xet_token_url(&self.endpoint, "read");
426
+ /// A cached token for `scope`, plus the URL/headers to configure
427
+ /// automatic refresh on a builder that outlives this call. Shared by
428
+ /// [`Self::xet_upload_commit`] and [`Self::xet_download_group`], which
429
+ /// otherwise differ only in which builder they seed with it. Uses the
430
+ /// canonical repo id so the refresh URL doesn't depend on redirect
431
+ /// behavior (see [`Self::canonical_repo`]). Resolves the repo once and
432
+ /// hands it to [`Self::cached_xet_token`] so the token lookup doesn't
433
+ /// resolve it again.
434
+ async fn xet_auth(
435
+ &self,
436
+ ctx: &OperationContext,
437
+ scope: XetTokenScope,
438
+ ) -> Result<(XetToken, String, http::HeaderMap)> {
439
+ let repo = self.canonical_repo(ctx).await?;
440
+ let token = self.cached_xet_token(ctx, &repo, scope).await?;
441
+ let refresh_url = repo.xet_token_url(&self.endpoint, scope);
344
442
  let refresh_headers = self.xet_token_refresh_headers();
443
+ Ok((token, refresh_url, refresh_headers))
444
+ }
445
+
446
+ /// Get a still-valid cached token for `scope` against the already
447
+ /// resolved `repo`, fetching and caching a fresh one if missing or
448
+ /// close to expiry. The lock is held across the refresh request so
449
+ /// concurrent callers single-flight onto one fetch; `repo` is resolved
450
+ /// by the caller beforehand so that lookup doesn't extend this
451
+ /// critical section.
452
+ ///
453
+ /// On a clock failure, `now` falls back to `u64::MAX` so the token is
454
+ /// treated as stale (extra `/api` traffic) rather than `0`, which would
455
+ /// pin a stale token as valid forever.
456
+ async fn cached_xet_token(
457
+ &self,
458
+ ctx: &OperationContext,
459
+ repo: &HfRepo,
460
+ scope: XetTokenScope,
461
+ ) -> Result<XetToken> {
462
+ let cache = match scope {
463
+ XetTokenScope::Read => &self.xet_read_token,
464
+ XetTokenScope::Write => &self.xet_write_token,
465
+ };
466
+ let mut cached = cache.lock().await;
467
+
468
+ let now = SystemTime::now()
469
+ .duration_since(UNIX_EPOCH)
470
+ .map(|d| d.as_secs())
471
+ .unwrap_or(u64::MAX);
472
+ if let Some(token) = cached.as_ref()
473
+ && token.expires_at > now.saturating_add(XET_TOKEN_REFRESH_BUFFER_SECS)
474
+ {
475
+ return Ok(token.clone());
476
+ }
477
+
478
+ let url = repo.xet_token_url(&self.endpoint, scope);
479
+ let req = self
480
+ .request(http::Method::GET, &url, scope.operation(), "XetToken")?
481
+ .body(Buffer::new())
482
+ .map_err(new_request_build_error)?;
483
+ let resp = ctx.http_transport().fetch(req).await?;
484
+ if !resp.status().is_success() {
485
+ let (parts, _) = resp.into_parts();
486
+ return Err(parse_error(
487
+ ErrorContext::new(ServiceOperation("XetToken")),
488
+ parts,
489
+ ));
490
+ }
491
+ let (_, mut body) = resp.into_parts();
492
+ let buffer = body.to_buffer().await?;
493
+ let info: XetTokenResponse =
494
+ serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)?;
495
+ let fresh = XetToken {
496
+ cas_url: info.cas_url,
497
+ access_token: info.access_token,
498
+ expires_at: info.exp,
499
+ };
500
+ *cached = Some(fresh.clone());
501
+ Ok(fresh)
502
+ }
503
+
504
+ /// Create a new XET download stream group, seeded with a cached CAS read
505
+ /// token so it doesn't have to fetch its own. Called once per `HfReader`
506
+ /// (see `xet_group` in reader.rs) rather than cached on `HfCore`: its
507
+ /// internal progress tracking (`GroupProgress.items`) has no eviction, so
508
+ /// sharing one group across every reader on an `Operator` would grow it
509
+ /// without bound. Scoping to a reader's lifetime keeps that growth
510
+ /// bounded by the reader instead. Same reasoning applies to
511
+ /// `xet_upload_commit`'s commit objects.
512
+ pub(super) async fn xet_download_group(
513
+ &self,
514
+ ctx: &OperationContext,
515
+ ) -> Result<XetDownloadStreamGroup> {
516
+ let (token, refresh_url, refresh_headers) = self.xet_auth(ctx, XetTokenScope::Read).await?;
345
517
  self.xet_session
346
518
  .new_download_stream_group()
347
519
  .map_err(|err| {
@@ -351,6 +523,8 @@ impl HfCore {
351
523
  )
352
524
  .set_source(err)
353
525
  })?
526
+ .with_endpoint(token.cas_url)
527
+ .with_token_info(token.access_token, token.expires_at)
354
528
  .with_token_refresh_url(refresh_url, refresh_headers)
355
529
  .build()
356
530
  .await
@@ -374,6 +548,12 @@ impl HfCore {
374
548
  op: Operation,
375
549
  service_operation: &'static str,
376
550
  ) -> Result<http::request::Builder> {
551
+ // Every outbound HF/CAS request is built here, so this is the one
552
+ // place that can show what's actually being queried -- enable with
553
+ // `RUST_LOG=opendal_service_hf::core=debug`.
554
+ debug!(
555
+ "hf request: service_operation={service_operation} operation={op} method={method} url={url}"
556
+ );
377
557
  let mut req = Request::builder()
378
558
  .method(method)
379
559
  .uri(url)
@@ -396,9 +576,58 @@ impl HfCore {
396
576
  Ok(req)
397
577
  }
398
578
 
399
- /// Build an [`HfUri`] for the given operator-relative path.
400
- pub(super) fn uri(&self, path: &str) -> HfUri {
401
- self.repo.uri(&self.root, path)
579
+ /// Return the repo handle with the server's canonical repo id, resolving
580
+ /// and caching it on first use.
581
+ ///
582
+ /// HF resolves repo ids case-insensitively but replies 307 to any request
583
+ /// for a non-canonically-cased id (e.g. `user/repo` for `user/Repo`).
584
+ /// The transport cannot replay bodied requests (commit, paths-info)
585
+ /// through a redirect. Building every URL from the canonical id avoids
586
+ /// depending on redirect behavior entirely, removing the inconsistency across
587
+ /// operations.
588
+ pub(super) async fn canonical_repo(&self, ctx: &OperationContext) -> Result<HfRepo> {
589
+ // Buckets are addressed by opaque ids
590
+ if self.repo.is_bucket() {
591
+ return Ok(self.repo.clone());
592
+ }
593
+
594
+ self.canonical_repo
595
+ .get_or_try_init(|| async {
596
+ let url = format!(
597
+ "{}/api/{}/{}",
598
+ self.endpoint,
599
+ self.repo.repo_type.as_plural_str(),
600
+ self.repo.repo_id,
601
+ );
602
+ let req = self
603
+ .request(http::Method::GET, &url, Operation::Stat, "RepoInfo")?
604
+ .body(Buffer::new())
605
+ .map_err(new_request_build_error)?;
606
+ let resp = ctx.http_transport().fetch(req).await?;
607
+ if !resp.status().is_success() {
608
+ let (parts, _) = resp.into_parts();
609
+ return Err(parse_error(
610
+ ErrorContext::new(ServiceOperation("RepoInfo")),
611
+ parts,
612
+ ));
613
+ }
614
+ let (_, mut body) = resp.into_parts();
615
+ let buffer = body.to_buffer().await?;
616
+ let info: RepoInfoResponse =
617
+ serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)?;
618
+
619
+ let mut repo = self.repo.clone();
620
+ repo.repo_id = info.id;
621
+ Ok(repo)
622
+ })
623
+ .await
624
+ .cloned()
625
+ }
626
+
627
+ /// Build an [`HfUri`] for the given operator-relative path, using the
628
+ /// canonical repo id.
629
+ pub(super) async fn canonical_uri(&self, ctx: &OperationContext, path: &str) -> Result<HfUri> {
630
+ Ok(self.canonical_repo(ctx).await?.uri(&self.root, path))
402
631
  }
403
632
 
404
633
  /// Convert an operator-relative path to a repo-absolute path
@@ -409,43 +638,8 @@ impl HfCore {
409
638
  .to_string()
410
639
  }
411
640
 
412
- /// Send a request and return the successful streaming response or a parsed error.
413
- ///
414
- /// Uses `fetch` so error response bodies are never read into memory —
415
- /// `parse_error` reads only response headers.
416
- async fn send(
417
- &self,
418
- ctx: &OperationContext,
419
- req: Request<Buffer>,
420
- ) -> Result<Response<HttpBody>> {
421
- let resp = ctx.http_transport().fetch(req).await?;
422
- let (parts, body) = resp.into_parts();
423
- if parts.status.is_success() {
424
- Ok(Response::from_parts(parts, body))
425
- } else {
426
- // Drop the streaming body without reading it.
427
- Err(parse_error(parts))
428
- }
429
- }
430
-
431
- /// Send a request, check for success, and deserialize the JSON response.
432
- ///
433
- /// Returns the response parts (status, headers, etc.) alongside the
434
- /// deserialized body so callers can inspect headers when needed.
435
- pub(super) async fn send_parse<T: serde::de::DeserializeOwned>(
436
- &self,
437
- ctx: &OperationContext,
438
- req: Request<Buffer>,
439
- ) -> Result<(http::response::Parts, T)> {
440
- let (parts, mut body) = self.send(ctx, req).await?.into_parts();
441
- let buffer = body.to_buffer().await?;
442
- let parsed =
443
- serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)?;
444
- Ok((parts, parsed))
445
- }
446
-
447
641
  pub(super) async fn path_info(&self, ctx: &OperationContext, path: &str) -> Result<PathInfo> {
448
- let uri = self.uri(path);
642
+ let uri = self.canonical_uri(ctx, path).await?;
449
643
  let url = uri.paths_info_url(&self.endpoint);
450
644
  let form_body = format!("paths={}&expand=True", percent_encode_path(&uri.path));
451
645
 
@@ -454,7 +648,18 @@ impl HfCore {
454
648
  .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
455
649
  .body(Buffer::from(Bytes::from(form_body)))
456
650
  .map_err(new_request_build_error)?;
457
- let (_, mut files) = self.send_parse::<Vec<PathInfo>>(ctx, req).await?;
651
+ let resp = ctx.http_transport().fetch(req).await?;
652
+ if !resp.status().is_success() {
653
+ let (parts, _) = resp.into_parts();
654
+ return Err(parse_error(
655
+ ErrorContext::new(ServiceOperation("PathInfo")),
656
+ parts,
657
+ ));
658
+ }
659
+ let (_, mut body) = resp.into_parts();
660
+ let buffer = body.to_buffer().await?;
661
+ let mut files: Vec<PathInfo> =
662
+ serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)?;
458
663
 
459
664
  // NOTE: if the file is not found, the server will return 200 with an empty array
460
665
  if files.is_empty() {
@@ -468,7 +673,9 @@ impl HfCore {
468
673
  ///
469
674
  /// In `Xet` mode adds `Accept: application/vnd.xet-fileinfo+json` so the
470
675
  /// server returns XET metadata instead of redirecting; in `Http` mode the
471
- /// redirect is followed and the file bytes are streamed directly.
676
+ /// redirect is followed and the file bytes are streamed directly. Either
677
+ /// way, the response may carry an `x-xet-hash` header -- interpreting
678
+ /// that is up to the caller, not this method's concern.
472
679
  pub(super) async fn resolve(
473
680
  &self,
474
681
  ctx: &OperationContext,
@@ -476,7 +683,7 @@ impl HfCore {
476
683
  range: BytesRange,
477
684
  mode: HfDownloadMode,
478
685
  ) -> Result<Response<HttpBody>> {
479
- let uri = self.uri(path);
686
+ let uri = self.canonical_uri(ctx, path).await?;
480
687
  let url = uri.resolve_url(&self.endpoint, self.repo.revision());
481
688
 
482
689
  let mut req = self.request(http::Method::GET, &url, Operation::Read, "Resolve")?;
@@ -498,7 +705,7 @@ impl HfCore {
498
705
  // only response headers, so there is no need to buffer the body
499
706
  // (which may be a large HTML error page).
500
707
  let (parts, _) = resp.into_parts();
501
- let mut err = parse_error(parts);
708
+ let mut err = parse_error(ErrorContext::new(ServiceOperation("Resolve")), parts);
502
709
  if status == http::StatusCode::NOT_FOUND && self.path_info(ctx, path).await.is_ok() {
503
710
  err = err.set_temporary();
504
711
  }
@@ -519,7 +726,10 @@ impl HfCore {
519
726
  deleted_files: Vec<DeletedFile>,
520
727
  deleted_folders: Vec<DeletedFolder>,
521
728
  ) -> Result<CommitResponse> {
522
- let url = self.repo.git_commit_url(&self.endpoint);
729
+ let url = self
730
+ .canonical_repo(ctx)
731
+ .await?
732
+ .git_commit_url(&self.endpoint);
523
733
 
524
734
  let payload = MixedCommitPayload {
525
735
  summary: "Commit via OpenDAL".to_string(),
@@ -538,8 +748,17 @@ impl HfCore {
538
748
  .body(Buffer::from(json_body))
539
749
  .map_err(new_request_build_error)?;
540
750
 
541
- let (_, resp) = self.send_parse::<CommitResponse>(ctx, req).await?;
542
- Ok(resp)
751
+ let resp = ctx.http_transport().fetch(req).await?;
752
+ if !resp.status().is_success() {
753
+ let (parts, _) = resp.into_parts();
754
+ return Err(parse_error(
755
+ ErrorContext::new(ServiceOperation("CommitGit")),
756
+ parts,
757
+ ));
758
+ }
759
+ let (_, mut body) = resp.into_parts();
760
+ let buffer = body.to_buffer().await?;
761
+ serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)
543
762
  }
544
763
 
545
764
  /// Commit file changes to a bucket repo via the NDJSON batch API.
@@ -573,7 +792,14 @@ impl HfCore {
573
792
  .body(Buffer::from(Bytes::from(body)))
574
793
  .map_err(new_request_build_error)?;
575
794
 
576
- self.send(ctx, req).await?;
795
+ let resp = ctx.http_transport().fetch(req).await?;
796
+ if !resp.status().is_success() {
797
+ let (parts, _) = resp.into_parts();
798
+ return Err(parse_error(
799
+ ErrorContext::new(ServiceOperation("CommitBucket")),
800
+ parts,
801
+ ));
802
+ }
577
803
  Ok(())
578
804
  }
579
805
  }
@@ -590,6 +816,21 @@ pub(crate) mod test_utils {
590
816
  pub(crate) struct MockHttpTransport {
591
817
  url: Arc<Mutex<Option<String>>>,
592
818
  body: Arc<Mutex<Option<String>>>,
819
+ request_count: Arc<Mutex<usize>>,
820
+ /// `exp` returned by the mocked `xet-{read,write}-token` endpoint.
821
+ xet_token_expires_at: Arc<Mutex<u64>>,
822
+ /// Number of upcoming `fetch` calls that must fail with a 500
823
+ /// before resuming normal responses.
824
+ fail_next: Arc<Mutex<usize>>,
825
+ /// When set, a `/resolve/` request carries `x-xet-hash` and the
826
+ /// mocked [`XetFileResponse`] body instead of plain bytes, so tests
827
+ /// can exercise the XET classification path without real network.
828
+ xet_file: Arc<Mutex<Option<XetFileResponse>>>,
829
+ /// `Range` header of the most recent XET-classifying `/resolve/`
830
+ /// request (one that hit the `xet_file` branch above), so tests can
831
+ /// tell which racing caller's range a single-flighted classification
832
+ /// actually sent.
833
+ classify_range_header: Arc<Mutex<Option<String>>>,
593
834
  }
594
835
 
595
836
  impl MockHttpTransport {
@@ -597,9 +838,36 @@ pub(crate) mod test_utils {
597
838
  Self {
598
839
  url: Arc::new(Mutex::new(None)),
599
840
  body: Arc::new(Mutex::new(None)),
841
+ request_count: Arc::new(Mutex::new(0)),
842
+ xet_token_expires_at: Arc::new(Mutex::new(u64::MAX)),
843
+ fail_next: Arc::new(Mutex::new(0)),
844
+ xet_file: Arc::new(Mutex::new(None)),
845
+ classify_range_header: Arc::new(Mutex::new(None)),
600
846
  }
601
847
  }
602
848
 
849
+ /// Controls the `exp` field returned by the mocked
850
+ /// `xet-{read,write}-token` endpoint for subsequent requests.
851
+ pub(crate) fn set_xet_token_expires_at(&self, expires_at: u64) {
852
+ *self.xet_token_expires_at.lock().unwrap() = expires_at;
853
+ }
854
+
855
+ /// Makes the next `n` calls to `fetch` return a 500 response instead
856
+ /// of their normal mocked payload.
857
+ pub(crate) fn fail_next_requests(&self, n: usize) {
858
+ *self.fail_next.lock().unwrap() = n;
859
+ }
860
+
861
+ /// Makes every subsequent `/resolve/` request report the path as
862
+ /// XET-backed with the given hash/size, via `x-xet-hash` and a
863
+ /// mocked [`XetFileResponse`] body.
864
+ pub(crate) fn set_xet_backed(&self, hash: &str, size: u64) {
865
+ *self.xet_file.lock().unwrap() = Some(XetFileResponse {
866
+ hash: hash.to_string(),
867
+ size,
868
+ });
869
+ }
870
+
603
871
  pub(crate) fn get_captured_url(&self) -> String {
604
872
  self.url.lock().unwrap().clone().unwrap()
605
873
  }
@@ -607,20 +875,77 @@ pub(crate) mod test_utils {
607
875
  pub(crate) fn get_captured_body(&self) -> String {
608
876
  self.body.lock().unwrap().clone().unwrap_or_default()
609
877
  }
878
+
879
+ pub(crate) fn request_count(&self) -> usize {
880
+ *self.request_count.lock().unwrap()
881
+ }
882
+
883
+ /// `Range` header of the most recent XET-classifying `/resolve/`
884
+ /// request, or `None` if that range was full (no `Range` header) or
885
+ /// no such request has happened yet.
886
+ pub(crate) fn get_captured_classify_range_header(&self) -> Option<String> {
887
+ self.classify_range_header.lock().unwrap().clone()
888
+ }
610
889
  }
611
890
 
612
891
  impl HttpTransport for MockHttpTransport {
613
892
  async fn fetch(&self, req: Request<Buffer>) -> Result<Response<HttpBody>> {
614
- *self.url.lock().unwrap() = Some(req.uri().to_string());
893
+ // Yield once so concurrent callers (`futures::join!` in the
894
+ // single-flight tests) actually interleave instead of running to
895
+ // completion one at a time -- without this, a fully synchronous
896
+ // mock never gives the executor a chance to poll a second caller
897
+ // while the first is still "in flight", so tests asserting on
898
+ // single-flighted locking would pass even if the locking were
899
+ // broken.
900
+ tokio::task::yield_now().await;
901
+ *self.request_count.lock().unwrap() += 1;
902
+
903
+ {
904
+ let mut fail_next = self.fail_next.lock().unwrap();
905
+ if *fail_next > 0 {
906
+ *fail_next -= 1;
907
+ return Ok(Response::builder()
908
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
909
+ .header("x-error-message", "mock injected failure")
910
+ .body(HttpBody::new(futures::stream::empty(), Some(0)))
911
+ .unwrap());
912
+ }
913
+ }
914
+
915
+ let url = req.uri().to_string();
916
+ *self.url.lock().unwrap() = Some(url.clone());
615
917
  *self.body.lock().unwrap() = Some(
616
918
  String::from_utf8(req.body().to_bytes().to_vec())
617
919
  .expect("request body must be utf-8 for test payloads"),
618
920
  );
619
921
 
620
- // Return a minimal valid JSON response for API requests
621
- let (body, content_length) = if req.uri().to_string().contains("/paths-info/")
622
- || req.uri().to_string().contains("/tree/")
922
+ if url.contains("/resolve/")
923
+ && let Some(info) = self.xet_file.lock().unwrap().clone()
623
924
  {
925
+ *self.classify_range_header.lock().unwrap() = req
926
+ .headers()
927
+ .get(header::RANGE)
928
+ .and_then(|v| v.to_str().ok())
929
+ .map(str::to_string);
930
+ let data = Bytes::from(format!(
931
+ r#"{{"hash":"{}","size":{}}}"#,
932
+ info.hash, info.size
933
+ ));
934
+ let size = data.len() as u64;
935
+ let buffer = Buffer::from(data);
936
+ return Ok(Response::builder()
937
+ .status(StatusCode::OK)
938
+ .header(header::CONTENT_LENGTH, size)
939
+ .header("x-xet-hash", info.hash)
940
+ .body(HttpBody::new(
941
+ futures::stream::iter(vec![Ok(buffer)]),
942
+ Some(size),
943
+ ))
944
+ .unwrap());
945
+ }
946
+
947
+ // Return a minimal valid JSON response for API requests
948
+ let (body, content_length) = if url.contains("/paths-info/") || url.contains("/tree/") {
624
949
  let data =
625
950
  Bytes::from(r#"[{"type":"file","oid":"abc123","size":100,"path":"test.txt"}]"#);
626
951
  let size = data.len() as u64;
@@ -629,7 +954,7 @@ pub(crate) mod test_utils {
629
954
  HttpBody::new(futures::stream::iter(vec![Ok(buffer)]), Some(size)),
630
955
  size,
631
956
  )
632
- } else if req.uri().to_string().contains("/commit/") {
957
+ } else if url.contains("/commit/") {
633
958
  let data = Bytes::from(r#"{}"#);
634
959
  let size = data.len() as u64;
635
960
  let buffer = Buffer::from(data);
@@ -637,6 +962,27 @@ pub(crate) mod test_utils {
637
962
  HttpBody::new(futures::stream::iter(vec![Ok(buffer)]), Some(size)),
638
963
  size,
639
964
  )
965
+ } else if url.contains("xet-read-token") || url.contains("xet-write-token") {
966
+ let exp = *self.xet_token_expires_at.lock().unwrap();
967
+ let data = Bytes::from(format!(
968
+ r#"{{"casUrl":"https://cas.example.com","exp":{exp},"accessToken":"mock-token"}}"#
969
+ ));
970
+ let size = data.len() as u64;
971
+ let buffer = Buffer::from(data);
972
+ (
973
+ HttpBody::new(futures::stream::iter(vec![Ok(buffer)]), Some(size)),
974
+ size,
975
+ )
976
+ } else if let Some(rest) = req.uri().path().strip_prefix("/api/") {
977
+ // Repo-info: echo the requested id back as the canonical id.
978
+ let id = rest.split_once('/').map(|(_, id)| id).unwrap_or(rest);
979
+ let data = Bytes::from(format!(r#"{{"id":"{id}"}}"#));
980
+ let size = data.len() as u64;
981
+ let buffer = Buffer::from(data);
982
+ (
983
+ HttpBody::new(futures::stream::iter(vec![Ok(buffer)]), Some(size)),
984
+ size,
985
+ )
640
986
  } else {
641
987
  let data = Bytes::from_static(b"hello");
642
988
  let size = data.len() as u64;
@@ -688,6 +1034,11 @@ pub(crate) mod test_utils {
688
1034
 
689
1035
  #[cfg(test)]
690
1036
  mod tests {
1037
+ use std::sync::{Arc, Mutex};
1038
+
1039
+ use http::Response;
1040
+ use http::StatusCode;
1041
+
691
1042
  use super::super::core::HfRepoType;
692
1043
  use super::test_utils::create_test_core;
693
1044
  use super::*;
@@ -771,69 +1122,407 @@ mod tests {
771
1122
 
772
1123
  Ok(())
773
1124
  }
774
- }
775
1125
 
776
- mod error {
777
- use http::StatusCode;
1126
+ /// A scripted transport mirroring how HF serves a repo whose configured id
1127
+ /// is not canonically cased: repo-info returns the canonical id, and only
1128
+ /// the canonical commit URL accepts the commit.
1129
+ #[derive(Clone, Default)]
1130
+ struct CanonicalCaseTransport {
1131
+ requests: Arc<Mutex<Vec<(String, String, String)>>>,
1132
+ }
778
1133
 
779
- use opendal_core::raw::*;
780
- use opendal_core::*;
781
-
782
- pub(crate) fn parse_error(parts: http::response::Parts) -> Error {
783
- // HF sets x-error-message on every error response with a short human-readable
784
- // description. Using the header avoids reading the response body, which can be
785
- // a large HTML error page (e.g. 52 KB on 404s from the /resolve/ endpoint).
786
- let message = parts
787
- .headers
788
- .get("x-error-message")
789
- .and_then(|v| v.to_str().ok())
790
- .unwrap_or("unknown error")
791
- .to_string();
792
-
793
- // HF git-style commit APIs reject stale branch snapshots with 412.
794
- // Treat this specific conflict as temporary so RetryLayer can replay
795
- // the whole write/delete close sequence on a fresh branch head.
796
- let branch_updated_conflict = parts.status == StatusCode::PRECONDITION_FAILED
797
- && message
798
- .to_ascii_lowercase()
799
- .contains("branch was updated since you opened this page");
800
-
801
- let (kind, retryable) = match parts.status {
802
- StatusCode::NOT_FOUND => (ErrorKind::NotFound, false),
803
- StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
804
- (ErrorKind::PermissionDenied, false)
805
- }
806
- StatusCode::PRECONDITION_FAILED => {
807
- (ErrorKind::ConditionNotMatch, branch_updated_conflict)
808
- }
809
- StatusCode::INTERNAL_SERVER_ERROR
810
- | StatusCode::BAD_GATEWAY
811
- | StatusCode::SERVICE_UNAVAILABLE
812
- | StatusCode::GATEWAY_TIMEOUT => (ErrorKind::Unexpected, true),
813
- _ => (ErrorKind::Unexpected, false),
1134
+ impl HttpTransport for CanonicalCaseTransport {
1135
+ async fn fetch(&self, req: Request<Buffer>) -> Result<Response<HttpBody>> {
1136
+ let uri = req.uri().to_string();
1137
+ let auth = req
1138
+ .headers()
1139
+ .get(header::AUTHORIZATION)
1140
+ .and_then(|v| v.to_str().ok())
1141
+ .unwrap_or_default()
1142
+ .to_string();
1143
+ self.requests
1144
+ .lock()
1145
+ .unwrap()
1146
+ .push((req.method().to_string(), uri.clone(), auth));
1147
+
1148
+ let body = match uri.as_str() {
1149
+ "https://huggingface.co/api/models/test-user/uppercase-repo" => {
1150
+ Bytes::from_static(br#"{"id":"test-user/Uppercase-Repo"}"#)
1151
+ }
1152
+ "https://huggingface.co/api/models/test-user/Uppercase-Repo/commit/main" => {
1153
+ Bytes::from_static(b"{}")
1154
+ }
1155
+ other => panic!("unexpected request to {other}"),
1156
+ };
1157
+ let len = body.len() as u64;
1158
+ Ok(Response::builder()
1159
+ .status(http::StatusCode::OK)
1160
+ .header(header::CONTENT_LENGTH, len)
1161
+ .body(HttpBody::new(
1162
+ futures::stream::iter(vec![Ok(Buffer::from(body))]),
1163
+ Some(len),
1164
+ ))
1165
+ .unwrap())
1166
+ }
1167
+ }
1168
+
1169
+ #[tokio::test]
1170
+ async fn test_commit_uses_canonical_repo_id() -> Result<()> {
1171
+ let transport = CanonicalCaseTransport::default();
1172
+ let ctx = OperationContext::from_parts(
1173
+ HttpTransporter::new(transport.clone()),
1174
+ Executor::default(),
1175
+ );
1176
+
1177
+ let xet_session = XetSessionBuilder::new()
1178
+ .build()
1179
+ .expect("failed to create xet session");
1180
+ let core = HfCore::new(
1181
+ ServiceInfo::new("hf", "", ""),
1182
+ Capability::default(),
1183
+ HfRepo::new(
1184
+ HfRepoType::Model,
1185
+ "test-user/uppercase-repo".to_string(),
1186
+ Some("main".to_string()),
1187
+ ),
1188
+ "/".to_string(),
1189
+ Some("hf_dummy".to_string()),
1190
+ "https://huggingface.co".to_string(),
1191
+ xet_session,
1192
+ HfDownloadMode::Xet,
1193
+ );
1194
+
1195
+ let lfs_file = |path: &str| LfsFile {
1196
+ path: path.to_string(),
1197
+ oid: "deadbeef".to_string(),
1198
+ algo: "sha256".to_string(),
1199
+ size: 2812,
814
1200
  };
1201
+ core.commit_git(&ctx, vec![], vec![lfs_file("a.md")], vec![], vec![])
1202
+ .await?;
1203
+ core.commit_git(&ctx, vec![], vec![lfs_file("b.md")], vec![], vec![])
1204
+ .await?;
1205
+
1206
+ let requests = transport.requests.lock().unwrap();
1207
+ let expected = [
1208
+ (
1209
+ "GET",
1210
+ "https://huggingface.co/api/models/test-user/uppercase-repo",
1211
+ ),
1212
+ (
1213
+ "POST",
1214
+ "https://huggingface.co/api/models/test-user/Uppercase-Repo/commit/main",
1215
+ ),
1216
+ // The canonical id is cached: no second repo-info request.
1217
+ (
1218
+ "POST",
1219
+ "https://huggingface.co/api/models/test-user/Uppercase-Repo/commit/main",
1220
+ ),
1221
+ ];
1222
+ assert_eq!(requests.len(), expected.len());
1223
+ for ((method, uri, auth), (exp_method, exp_uri)) in requests.iter().zip(expected) {
1224
+ assert_eq!(method, exp_method);
1225
+ assert_eq!(uri, exp_uri);
1226
+ assert_eq!(auth, "Bearer hf_dummy");
1227
+ }
815
1228
 
816
- let mut err = Error::new(kind, message);
1229
+ Ok(())
1230
+ }
817
1231
 
818
- err = with_error_response_context(err, parts);
1232
+ #[tokio::test]
1233
+ async fn test_xet_read_token_is_cached_across_calls() -> Result<()> {
1234
+ let (core, ctx, mock_client) = create_test_core(
1235
+ HfRepoType::Model,
1236
+ "test-user/test-repo",
1237
+ "main",
1238
+ "https://huggingface.co",
1239
+ );
1240
+ mock_client.set_xet_token_expires_at(u64::MAX);
1241
+
1242
+ // 2 requests: one to resolve the canonical repo id, one for the
1243
+ // token itself.
1244
+ let repo = core.canonical_repo(&ctx).await?;
1245
+ let first = core
1246
+ .cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1247
+ .await?;
1248
+ assert_eq!(mock_client.request_count(), 2);
1249
+ assert_eq!(first.access_token, "mock-token");
1250
+ assert_eq!(first.cas_url, "https://cas.example.com");
1251
+
1252
+ // A second call with the cached token nowhere near expiry must not
1253
+ // hit the network again -- the canonical repo id is cached too.
1254
+ let second = core
1255
+ .cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1256
+ .await?;
1257
+ assert_eq!(mock_client.request_count(), 2);
1258
+ assert_eq!(second.access_token, first.access_token);
819
1259
 
820
- if retryable {
821
- err = err.set_temporary();
1260
+ Ok(())
1261
+ }
1262
+
1263
+ #[tokio::test]
1264
+ async fn test_xet_read_token_refreshes_once_stale() -> Result<()> {
1265
+ let (core, ctx, mock_client) = create_test_core(
1266
+ HfRepoType::Model,
1267
+ "test-user/test-repo",
1268
+ "main",
1269
+ "https://huggingface.co",
1270
+ );
1271
+ // Already within the refresh buffer of "now" -- immediately stale.
1272
+ mock_client.set_xet_token_expires_at(0);
1273
+
1274
+ // 2 requests: one to resolve the canonical repo id, one for the
1275
+ // token itself.
1276
+ let repo = core.canonical_repo(&ctx).await?;
1277
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1278
+ .await?;
1279
+ assert_eq!(mock_client.request_count(), 2);
1280
+
1281
+ // The canonical repo id is cached, so only the token is re-fetched.
1282
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1283
+ .await?;
1284
+ assert_eq!(
1285
+ mock_client.request_count(),
1286
+ 3,
1287
+ "a token that's always stale must be refreshed on every call, not cached"
1288
+ );
1289
+
1290
+ Ok(())
1291
+ }
1292
+
1293
+ /// A failed refresh must not poison the cache: the next call should
1294
+ /// retry against the network rather than being permanently stuck on an
1295
+ /// error or on a stale/absent token.
1296
+ #[tokio::test]
1297
+ async fn test_cached_xet_token_retries_after_fetch_failure() -> Result<()> {
1298
+ let (core, ctx, mock_client) = create_test_core(
1299
+ HfRepoType::Model,
1300
+ "test-user/test-repo",
1301
+ "main",
1302
+ "https://huggingface.co",
1303
+ );
1304
+ mock_client.set_xet_token_expires_at(u64::MAX);
1305
+ mock_client.fail_next_requests(1);
1306
+
1307
+ // The injected failure hits the canonical repo id lookup, the first
1308
+ // request resolving it makes.
1309
+ match core.canonical_repo(&ctx).await {
1310
+ Err(err) => assert!(err.to_string().contains("mock injected failure")),
1311
+ Ok(_) => panic!("a failed canonical repo lookup must surface as an error"),
822
1312
  }
1313
+ assert_eq!(mock_client.request_count(), 1);
823
1314
 
824
- err
1315
+ // Retry: canonical repo id lookup succeeds, then the token itself.
1316
+ let repo = core.canonical_repo(&ctx).await?;
1317
+ let token = core
1318
+ .cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1319
+ .await?;
1320
+ assert_eq!(token.access_token, "mock-token");
1321
+ assert_eq!(mock_client.request_count(), 3);
1322
+
1323
+ Ok(())
825
1324
  }
826
1325
 
827
- #[cfg(test)]
828
- mod test {
829
- use http::Response;
830
- use http::StatusCode;
1326
+ #[tokio::test]
1327
+ async fn test_xet_read_and_write_tokens_are_cached_independently() -> Result<()> {
1328
+ let (mut core, ctx, mock_client) = create_test_core(
1329
+ HfRepoType::Model,
1330
+ "test-user/test-repo",
1331
+ "main",
1332
+ "https://huggingface.co",
1333
+ );
1334
+ // A write-scope token fetch now goes through `request()` tagged as
1335
+ // `Operation::Write`, which requires a token to be configured.
1336
+ core.token = Some("hf_dummy".to_string());
1337
+ mock_client.set_xet_token_expires_at(u64::MAX);
1338
+
1339
+ // 2 requests: one to resolve the canonical repo id, one for the
1340
+ // read token itself.
1341
+ let repo = core.canonical_repo(&ctx).await?;
1342
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1343
+ .await?;
1344
+ assert_eq!(mock_client.request_count(), 2);
1345
+
1346
+ // A write token request must not be satisfied by the read token's
1347
+ // cache slot -- read and write are distinct HF API scopes. The
1348
+ // canonical repo id is already cached, so only the token is fetched.
1349
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Write)
1350
+ .await?;
1351
+ assert_eq!(mock_client.request_count(), 3);
1352
+ assert!(mock_client.get_captured_url().contains("write"));
1353
+
1354
+ // Both are now warm; neither call should hit the network again.
1355
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1356
+ .await?;
1357
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Write)
1358
+ .await?;
1359
+ assert_eq!(mock_client.request_count(), 3);
831
1360
 
832
- use super::*;
1361
+ Ok(())
1362
+ }
833
1363
 
834
- #[test]
835
- fn test_parse_error_branch_update_conflict_is_temporary() {
836
- let (parts, _) = Response::builder()
1364
+ /// The point of caching isn't just `cached_xet_token` in isolation --
1365
+ /// `xet_download_group`/`xet_upload_commit` must actually go through it.
1366
+ /// A regression that bypassed the cache in their wiring wouldn't be
1367
+ /// caught by the `cached_xet_token`-only tests above.
1368
+ #[tokio::test]
1369
+ async fn test_xet_download_group_reuses_cached_read_token() -> Result<()> {
1370
+ let (core, ctx, mock_client) = create_test_core(
1371
+ HfRepoType::Model,
1372
+ "test-user/test-repo",
1373
+ "main",
1374
+ "https://huggingface.co",
1375
+ );
1376
+ mock_client.set_xet_token_expires_at(u64::MAX);
1377
+
1378
+ // 2 requests: one to resolve the canonical repo id, one for the
1379
+ // read token itself.
1380
+ core.xet_download_group(&ctx).await?;
1381
+ assert_eq!(mock_client.request_count(), 2);
1382
+
1383
+ // A second group build must reuse the cached read token (and
1384
+ // canonical repo id) rather than fetching its own.
1385
+ core.xet_download_group(&ctx).await?;
1386
+ assert_eq!(mock_client.request_count(), 2);
1387
+
1388
+ Ok(())
1389
+ }
1390
+
1391
+ #[tokio::test]
1392
+ async fn test_xet_upload_commit_reuses_cached_write_token() -> Result<()> {
1393
+ let (mut core, ctx, mock_client) = create_test_core(
1394
+ HfRepoType::Model,
1395
+ "test-user/test-repo",
1396
+ "main",
1397
+ "https://huggingface.co",
1398
+ );
1399
+ core.token = Some("hf_dummy".to_string());
1400
+ mock_client.set_xet_token_expires_at(u64::MAX);
1401
+
1402
+ // 2 requests: one to resolve the canonical repo id, one for the
1403
+ // write token itself.
1404
+ core.xet_upload_commit(&ctx).await?;
1405
+ assert_eq!(mock_client.request_count(), 2);
1406
+ assert!(mock_client.get_captured_url().contains("write"));
1407
+
1408
+ // A second commit build must reuse the cached write token (and
1409
+ // canonical repo id) rather than fetching its own.
1410
+ core.xet_upload_commit(&ctx).await?;
1411
+ assert_eq!(mock_client.request_count(), 2);
1412
+
1413
+ Ok(())
1414
+ }
1415
+
1416
+ /// A write-token fetch must be tagged `Operation::Write`, not `Read`, so
1417
+ /// a token-less writer hits `request()`'s local `PermissionDenied`
1418
+ /// fast-path instead of firing a real unauthenticated network call.
1419
+ #[tokio::test]
1420
+ async fn test_xet_upload_commit_without_token_fails_locally() -> Result<()> {
1421
+ let (core, ctx, mock_client) = create_test_core(
1422
+ HfRepoType::Model,
1423
+ "test-user/test-repo",
1424
+ "main",
1425
+ "https://huggingface.co",
1426
+ );
1427
+ mock_client.set_xet_token_expires_at(u64::MAX);
1428
+
1429
+ match core.xet_upload_commit(&ctx).await {
1430
+ Err(err) => assert_eq!(err.kind(), ErrorKind::PermissionDenied),
1431
+ Ok(_) => panic!("an upload commit without a token must fail locally"),
1432
+ }
1433
+ // The canonical repo id lookup doesn't require a token, but the
1434
+ // write-token fetch itself must be rejected before it reaches the
1435
+ // network.
1436
+ assert_eq!(mock_client.request_count(), 1);
1437
+
1438
+ Ok(())
1439
+ }
1440
+
1441
+ /// Locks in the strict `>` in `cached_xet_token`'s freshness check: a
1442
+ /// token expiring exactly at the edge of the refresh buffer counts as
1443
+ /// stale (refetched), not fresh.
1444
+ #[tokio::test]
1445
+ async fn test_token_at_refresh_buffer_boundary_is_stale() -> Result<()> {
1446
+ let (core, ctx, mock_client) = create_test_core(
1447
+ HfRepoType::Model,
1448
+ "test-user/test-repo",
1449
+ "main",
1450
+ "https://huggingface.co",
1451
+ );
1452
+
1453
+ let now = SystemTime::now()
1454
+ .duration_since(UNIX_EPOCH)
1455
+ .unwrap()
1456
+ .as_secs();
1457
+
1458
+ // Exactly at the buffer boundary: `expires_at > now + BUFFER` is
1459
+ // false, so this must be treated as stale. The first call also pays
1460
+ // for the one-time canonical repo id lookup.
1461
+ mock_client.set_xet_token_expires_at(now + XET_TOKEN_REFRESH_BUFFER_SECS);
1462
+ let repo = core.canonical_repo(&ctx).await?;
1463
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1464
+ .await?;
1465
+ assert_eq!(mock_client.request_count(), 2);
1466
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1467
+ .await?;
1468
+ assert_eq!(
1469
+ mock_client.request_count(),
1470
+ 3,
1471
+ "a token expiring exactly at the refresh buffer must be refetched, not reused"
1472
+ );
1473
+
1474
+ // One second past the boundary: now fresh, so cached and reused.
1475
+ mock_client.set_xet_token_expires_at(now + XET_TOKEN_REFRESH_BUFFER_SECS + 1);
1476
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1477
+ .await?;
1478
+ assert_eq!(mock_client.request_count(), 4);
1479
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read)
1480
+ .await?;
1481
+ assert_eq!(
1482
+ mock_client.request_count(),
1483
+ 4,
1484
+ "a token expiring past the refresh buffer must be cached and reused"
1485
+ );
1486
+
1487
+ Ok(())
1488
+ }
1489
+
1490
+ /// The whole point of holding the cache's mutex across the refresh
1491
+ /// call: concurrent callers racing on a stale/missing token queue
1492
+ /// behind one in-flight refresh instead of each firing their own.
1493
+ #[tokio::test]
1494
+ async fn test_concurrent_stale_token_refresh_is_single_flighted() -> Result<()> {
1495
+ let (core, ctx, mock_client) = create_test_core(
1496
+ HfRepoType::Model,
1497
+ "test-user/test-repo",
1498
+ "main",
1499
+ "https://huggingface.co",
1500
+ );
1501
+ mock_client.set_xet_token_expires_at(u64::MAX);
1502
+
1503
+ let repo = core.canonical_repo(&ctx).await?;
1504
+ let (r1, r2, r3) = futures::join!(
1505
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read),
1506
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read),
1507
+ core.cached_xet_token(&ctx, &repo, XetTokenScope::Read),
1508
+ );
1509
+ r1?;
1510
+ r2?;
1511
+ r3?;
1512
+
1513
+ assert_eq!(
1514
+ mock_client.request_count(),
1515
+ 2,
1516
+ "concurrent callers racing on a cold/stale token must share one refresh \
1517
+ (plus the one-time canonical repo id lookup)"
1518
+ );
1519
+
1520
+ Ok(())
1521
+ }
1522
+
1523
+ #[test]
1524
+ fn test_parse_error_branch_update_conflict_is_temporary() {
1525
+ let (parts, _) = Response::builder()
837
1526
  .status(StatusCode::PRECONDITION_FAILED)
838
1527
  .header(
839
1528
  "x-error-message",
@@ -843,35 +1532,92 @@ mod error {
843
1532
  .unwrap()
844
1533
  .into_parts();
845
1534
 
846
- let err = parse_error(parts);
1535
+ let err = parse_error(ErrorContext::new(ServiceOperation("Test")), parts);
847
1536
 
848
- assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
849
- assert!(err.is_temporary());
850
- }
1537
+ assert_eq!(err.kind(), ErrorKind::Conflict);
1538
+ assert!(err.is_temporary());
1539
+ }
851
1540
 
852
- #[test]
853
- fn test_parse_error_other_precondition_failed_is_not_temporary() {
854
- let (parts, _) = Response::builder()
855
- .status(StatusCode::PRECONDITION_FAILED)
856
- .header("x-error-message", "etag mismatch")
857
- .body(())
858
- .unwrap()
859
- .into_parts();
1541
+ #[test]
1542
+ fn test_parse_error_other_precondition_failed_is_not_temporary() {
1543
+ let (parts, _) = Response::builder()
1544
+ .status(StatusCode::PRECONDITION_FAILED)
1545
+ .header("x-error-message", "etag mismatch")
1546
+ .body(())
1547
+ .unwrap()
1548
+ .into_parts();
860
1549
 
861
- let err = parse_error(parts);
1550
+ let err = parse_error(ErrorContext::new(ServiceOperation("Test")), parts);
862
1551
 
863
- assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
864
- assert!(!err.is_temporary());
865
- }
1552
+ assert_eq!(err.kind(), ErrorKind::Unexpected);
1553
+ assert!(!err.is_temporary());
1554
+ }
1555
+ }
1556
+
1557
+ use http::StatusCode;
1558
+
1559
+ /// Context needed to classify an error from this service.
1560
+ #[derive(Clone, Copy, Debug)]
1561
+ pub(crate) struct ErrorContext {
1562
+ service_operation: ServiceOperation,
1563
+ }
1564
+
1565
+ impl ErrorContext {
1566
+ pub(crate) const fn new(service_operation: ServiceOperation) -> Self {
1567
+ Self { service_operation }
866
1568
  }
867
1569
  }
868
1570
 
869
- pub(super) use error::*;
1571
+ /// Parse an error response using its service request context.
1572
+ pub(crate) fn parse_error(ctx: ErrorContext, parts: http::response::Parts) -> Error {
1573
+ // HF sets x-error-message on every error response with a short human-readable
1574
+ // description. Using the header avoids reading the response body, which can be
1575
+ // a large HTML error page (e.g. 52 KB on 404s from the /resolve/ endpoint).
1576
+ let message = parts
1577
+ .headers
1578
+ .get("x-error-message")
1579
+ .and_then(|v| v.to_str().ok())
1580
+ .unwrap_or("unknown error")
1581
+ .to_string();
1582
+
1583
+ // HF git-style commit APIs reject stale branch snapshots with 412.
1584
+ // Treat this specific conflict as temporary so RetryLayer can replay
1585
+ // the whole write/delete close sequence on a fresh branch head.
1586
+ let branch_updated_conflict = parts.status == StatusCode::PRECONDITION_FAILED
1587
+ && message
1588
+ .to_ascii_lowercase()
1589
+ .contains("branch was updated since you opened this page");
1590
+
1591
+ let (kind, retryable) = match parts.status {
1592
+ StatusCode::NOT_FOUND => (ErrorKind::NotFound, false),
1593
+ StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => (ErrorKind::PermissionDenied, false),
1594
+ StatusCode::PRECONDITION_FAILED if branch_updated_conflict => (ErrorKind::Conflict, true),
1595
+ StatusCode::PRECONDITION_FAILED => (ErrorKind::Unexpected, false),
1596
+ StatusCode::TOO_MANY_REQUESTS => (ErrorKind::RateLimited, true),
1597
+ StatusCode::INTERNAL_SERVER_ERROR
1598
+ | StatusCode::BAD_GATEWAY
1599
+ | StatusCode::SERVICE_UNAVAILABLE
1600
+ | StatusCode::GATEWAY_TIMEOUT => (ErrorKind::Unexpected, true),
1601
+ _ => (ErrorKind::Unexpected, false),
1602
+ };
1603
+
1604
+ let mut err = Error::new(kind, message);
1605
+
1606
+ err = err.with_context("service_operation", ctx.service_operation.0);
1607
+ err = with_error_response_context(err, parts);
1608
+
1609
+ if retryable {
1610
+ err = err.set_temporary();
1611
+ }
1612
+
1613
+ err
1614
+ }
870
1615
 
871
1616
  mod uri {
872
1617
  use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
873
1618
 
874
1619
  pub use super::HfRepoType;
1620
+ use super::XetTokenScope;
875
1621
  use crate::HUGGINGFACE_SCHEME;
876
1622
  use opendal_core::raw::*;
877
1623
 
@@ -931,7 +1677,8 @@ mod uri {
931
1677
  }
932
1678
 
933
1679
  /// Build the XET token API URL for this repository.
934
- pub fn xet_token_url(&self, endpoint: &str, token_type: &str) -> String {
1680
+ pub fn xet_token_url(&self, endpoint: &str, scope: XetTokenScope) -> String {
1681
+ let token_type = scope.as_str();
935
1682
  match self.repo_type {
936
1683
  HfRepoType::Bucket => {
937
1684
  format!(
@@ -1056,21 +1803,15 @@ mod uri {
1056
1803
 
1057
1804
  // Match special refs: refs/(convert|pr)/<segment>
1058
1805
  if let Some(rest) = rev_and_path.strip_prefix("refs/convert/") {
1059
- return if let Some(slash) = rest.find('/') {
1060
- (
1061
- rev_and_path[..14 + slash].to_string(),
1062
- rest[slash + 1..].to_string(),
1063
- )
1064
- } else {
1065
- (rev_and_path.to_string(), String::new())
1806
+ return match rest.split_once('/') {
1807
+ Some((segment, path)) => (format!("refs/convert/{segment}"), path.to_string()),
1808
+ None => (rev_and_path.to_string(), String::new()),
1066
1809
  };
1067
1810
  }
1068
1811
  if let Some(rest) = rev_and_path.strip_prefix("refs/pr/") {
1069
- return if let Some(slash) = rest.find('/') {
1070
- let revision = format!("refs/pr/{}", &rest[..slash]);
1071
- (revision, rest[slash + 1..].to_string())
1072
- } else {
1073
- (rev_and_path.to_string(), String::new())
1812
+ return match rest.split_once('/') {
1813
+ Some((segment, path)) => (format!("refs/pr/{segment}"), path.to_string()),
1814
+ None => (rev_and_path.to_string(), String::new()),
1074
1815
  };
1075
1816
  }
1076
1817
 
@@ -1266,6 +2007,15 @@ mod uri {
1266
2007
  assert_eq!(p.path, "");
1267
2008
  }
1268
2009
 
2010
+ #[test]
2011
+ fn resolve_refs_convert_revision_with_path() {
2012
+ let p = resolve("datasets/squad@refs/convert/parquet/default/train/0000.parquet");
2013
+ assert_eq!(p.repo.repo_type, HfRepoType::Dataset);
2014
+ assert_eq!(p.repo.repo_id, "squad");
2015
+ assert_eq!(p.repo.revision.as_deref(), Some("refs/convert/parquet"));
2016
+ assert_eq!(p.path, "default/train/0000.parquet");
2017
+ }
2018
+
1269
2019
  #[test]
1270
2020
  fn resolve_refs_pr_revision() {
1271
2021
  let p = resolve("username/my_model@refs/pr/10");
@@ -1384,8 +2134,12 @@ mod uri {
1384
2134
  #[test]
1385
2135
  fn test_bucket_xet_token_urls() {
1386
2136
  let p = resolve("buckets/user/bucket");
1387
- let read_url = p.repo.xet_token_url("https://huggingface.co", "read");
1388
- let write_url = p.repo.xet_token_url("https://huggingface.co", "write");
2137
+ let read_url = p
2138
+ .repo
2139
+ .xet_token_url("https://huggingface.co", XetTokenScope::Read);
2140
+ let write_url = p
2141
+ .repo
2142
+ .xet_token_url("https://huggingface.co", XetTokenScope::Write);
1389
2143
  assert_eq!(
1390
2144
  read_url,
1391
2145
  "https://huggingface.co/api/buckets/user/bucket/xet-read-token"