alkgit_substrate/lfs_adapter.rs
1//! The LFS batch/transfer adapter (wave 9) — the storage-side logic of
2//! the LFS protocol's batch/checking and byte paths, as a substrate
3//! surface under the `lfs` feature (ADR-022 §1–2; lfs.md §1–2; the
4//! substrate-side piece of ADR-025's LFS-surface member). The alkgit
5//! core has no native LFS op set (git-lfs speaks HTTP only) — the door
6//! is the alkhttp `lfs` feature (family infrastructure); this surface
7//! is what that door consumes, front-door-blind: it carries the
8//! storage-side mapping and never touches HTTP.
9//!
10//! # The adapter/door split (the wave's spec-level call, pinned here
11//! for the gate)
12//!
13//! ADR-025's neutral record names the door-side batch mapping as
14//! riding alkblobs directly; the row assertion is the
15//! composition-owned piece only this surface carries. Drawn exactly:
16//!
17//! **The door's side (never here):** the JSON batch protocol parsing
18//! and the HTTP route surface; the auth plumbing (alkhttp ADR-073's
19//! Basic-userinfo tolerance on the batch); the **anonymous-public
20//! `anon` identity synthesis** (the adapter refuses an absent
21//! identity — the door synthesizes the identity, the grant
22//! short-circuits on the public-repo flag); **href/token minting**
23//! ([`LfsHrefMint`] is the mint-input carrier — the door mints the
24//! absolute URLs with per-action `Authorization`; the adapter never
25//! mints a URL or a door token); HTTP `Range`-header parsing and the
26//! 206/416 responses; the object-size ceiling choice (the budget per
27//! assembly, the door's [`LfsAdapterConfig::max_object_bytes`]); and
28//! the batch request's `transfers` negotiation (the adapter carries
29//! no negotiation input and answers `basic` — the door reads the
30//! client's list and refuses a request that excludes `basic` before
31//! the batch reaches the adapter; the wave-9 gate's recorded door
32//! duty, lfs.md §1's pinned shape).
33//!
34//! **The adapter's side (this surface):** the batch's per-object
35//! existence answered over the ops control plane — `blobs/stat`
36//! through an
37//! [`OperationContext`]
38//! built with the caller's identity and the *wired* ownership
39//! provider (the POC-L door's DirectOps binding shape as a substrate
40//! surface); the response objects' shapes (`{oid, size,
41//! authenticated}` + the per-object action grant-carriers); **the row
42//! assertion at check time** (the moment the server learns the pair —
43//! lfs.md §3, over the [`LfsRows`] surface); the
44//! byte paths: the **pre-keyed put** (upload) and the **ranged fetch
45//! with per-window slice-digest verification** (download); the
46//! wired-provider requirement (never the static fallback).
47//!
48//! **What rides alkblobs directly (no substrate surface):** the JSON
49//! batch protocol itself is the door's; the facade seam
50//! (`Store::put_as(GitObjectSha256, len, stream)` / `read_range`) is
51//! the in-process embedder alternative with no ops plane at all —
52//! deliberately not re-exposed here (the embedder binds
53//! [`SubstrateAssembly::blobs`](crate::compose::SubstrateAssembly::blobs)
54//! directly; the adapter is the ops-plane surface). The wire ops
55//! (`blobs/put` / `blobs/fetch`, the channel data plane) remain the
56//! fleet/remote consumer's form — the DirectOps-vs-loopback binding
57//! choice sits door-side.
58//!
59//! # The pinned protocol facts (lfs.md §1, riding the shapes)
60//!
61//! - `hash_algo` answers `sha256` only: a batch carrying any other
62//! declared algorithm is refused ([`LfsAdapterError::HashAlgo`] —
63//! the door's 409). `verify` is not served: no batch path produces
64//! a verify action.
65//! - Transfer is `basic`; the download action's mint input is the
66//! whole media oid (range negotiation never rides the batch — the
67//! client's `Range` rides the transfer GET).
68//! - The upload key is **formable in advance** (ADR-022 §1: under
69//! `Algorithm::GitObjectSha256` the oid *is* the digest — the key is
70//! `[0x04] + oid bytes`): existence is a direct `blobs/stat` on the
71//! formed key (the translate-then-stat is direct; there is no
72//! mapping row, and no `have` use — that is the replication diff
73//! verb).
74//!
75//! # The byte paths (ADR-022 §2)
76//!
77//! - **Upload — the pre-keyed put.** The offer carries the claimed
78//! oid and the batch's size; a size-less offer is a protocol error
79//! ([`LfsAdapterError::SizelessOffer`] — LFS clients always send
80//! size; the door refuses before the body). The put rides the put
81//! handler's contract minus the channel: the one-read tee (a sha256
82//! pass alongside a bounded pipe the facade put consumes
83//! concurrently via `put_as(GitObjectSha256, Some(len), …)`), the
84//! claim cross-check **before the facade's EOF** — a body the
85//! derivation contradicts kills the put pre-commit
86//! ([`LfsAdapterError::MislabeledBody`]: no stored content, no held
87//! pin — the zero-residue discipline the put's stage hygiene
88//! carries on both tiers) — then the custody hand-over through the
89//! [`OpsContext`] verbs: fleet
90//! (`mint_fleet`, then the immediate `confirm` — the upload is
91//! complete, so the token's ticket retires at the confirmation and
92//! the mint boundary's admission cap stays live per upload, ADR-022
93//! §5; the fleet pin row's TTL is the durable protection — a
94//! dropped unconfirmed ticket would hold an admission slot until
95//! process end, the wave-9 gate's fix) or non-fleet
96//! (`mint_custodial` + `confirm`, the LFS pattern POC-L validated;
97//! a mint refusal at
98//! the admission cap is [`LfsAdapterError::MintRefused`], answering
99//! the already-committed put honestly). The reference row is
100//! asserted at upload time too (batch check or upload — lfs.md §3).
101//! - **Download — the ranged fetch.** Every download serves verified
102//! windows: the requested range (or the whole entry) is split
103//! internally at [`LfsAdapterConfig::window_bytes`] and each window
104//! rides `Store::read_range`, whose ADR-006 slice digest is checked
105//! mid-flight — a window's bytes are yielded only after the
106//! independently computed sha256 of exactly the returned window
107//! bytes matches the read's slice digest; a mismatch aborts the
108//! stream mid-flight (the caller re-fetches — the ranged arm's
109//! answer is the mid-flight-verified shape). **The resolved span
110//! is budget-gated** by [`LfsAdapterConfig::max_object_bytes`] —
111//! every form the adapter will actually produce counts (the
112//! range-less whole serve, the open-ended resume, any resolved
113//! span; `bytes=0-` is a whole download and refuses over the
114//! ceiling — the wave-9 gate's fix, the whole-arm budget's
115//! bypass closed). **Verification, stated precisely** (the
116//! gate's fix): a whole-span download additionally rides the
117//! end-to-end oid digest check — the windows' bytes accumulate
118//! into one sha256 pass, and the finished digest is compared
119//! against the key's derivation digest (the identity family's
120//! digest is the raw content's hash, so the comparison is the oid
121//! verification the large tier's reads carry no re-derivation
122//! for) **before the last window yields**. A partial span's
123//! windows carry the length + slice checks only — the store
124//! interior's ADR-006 digests, not an oid conclusion. **The
125//! pinned window-splitting call** (drawn against lfs.md's "the
126//! door owns window splitting", flagged for the gate): that fact
127//! governs the door binding the *ops byte-path* (the
128//! loopback/remote forms, where `fetch_max_total_bytes` bounds
129//! the op's ranged arm). This side of the HTTP boundary the
130//! adapter owns the arithmetic: a download call splits internally
131//! at `window_bytes` per verified window; the door's HTTP work is
132//! Range parsing and the budget, not window counting.
133//!
134//! # The wired ownership provider (alkcall ADR-048's posture, composed)
135//!
136//! The adapter takes the ownership provider **as a required
137//! constructor argument** — there is no Option, no static path, and
138//! every context it builds wires
139//! `ownership: Some(provider)`: the static `Identity.resources`
140//! fallback is unreachable through this surface (the POC-L
141//! gateway-gap regression's posture; the battery pins it — an
142//! identity carrying a static resources claim is refused unless the
143//! wired provider grants). The shipped provider is
144//! [`LfsGrantProvider`]: alkgit's registry composed onto the ops ACL
145//! — the same per-repo grants the git surface composes (ADR-011's
146//! posture), served through the cached read provider's sync resolve.
147//! alkcall ADR-048's loud default guards the gateway dispatch spine
148//! (the wire path); here the guard is the constructor requirement
149//! plus the battery. The `anon` composition is door-side (above).
150
151use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
152use std::sync::Arc;
153use std::task::{Context as PollContext, Poll};
154use std::time::{SystemTime, UNIX_EPOCH};
155
156use alkblobs::ops::{stat_pair, OpsConfig, OpsContext, PinConverter, BLOB_MISSING};
157use alkblobs::{Key, Pin, Store as BlobStore};
158use alkcall::core::auth::Identity;
159use alkcall::core::ownership::OwnershipProvider;
160use alkcall::core::types::Capabilities;
161use alkcall::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
162use alkcall::registry::env::{LocalOperationEnv, OperationEnv};
163use alkcall::registry::registration::OperationRegistry;
164use alkcall::OperationRegistryBuilder;
165use sha2::{Digest as _, Sha256};
166use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
167use tokio::sync::mpsc;
168
169use crate::error::OpenError;
170use crate::lfs::LfsRows;
171use crate::liveness::lfs_media_key;
172use crate::registry::RegistryRead;
173
174/// The batch/transfer adapter's knobs (the door budgets the ceiling;
175/// the adapter splits at the window). Defaults are the POC-L datums:
176/// the 32 MiB split window and the `fetch_max_total_bytes`-shaped
177/// 64 MiB object ceiling (raised per assembly to trade opens for
178/// windows — lfs.md §2).
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct LfsAdapterConfig {
181 /// The download windows' boundary — the size each verified
182 /// `read_range` window serves before the next (the POC-L split
183 /// datum, 32 MiB). Must be non-zero.
184 pub window_bytes: u64,
185 /// The download span ceiling (the door's budget per assembly —
186 /// lfs.md §2's "the door budgets the whole arm", realized
187 /// adapter-side): the **resolved span** of any download form is
188 /// the budgeted quantity — a range-less whole serve, an
189 /// open-ended resume (`bytes=0-`), or any resolved span over the
190 /// ceiling refuses [`LfsAdapterError::TooLarge`]; a partial span
191 /// under it rides the window arithmetic (a resume's legitimate
192 /// use).
193 pub max_object_bytes: u64,
194}
195
196impl Default for LfsAdapterConfig {
197 fn default() -> Self {
198 LfsAdapterConfig {
199 window_bytes: 32 * 1024 * 1024,
200 max_object_bytes: 64 * 1024 * 1024,
201 }
202 }
203}
204
205/// The batch API's operation half (the door maps the wire's
206/// `"upload"` / `"download"` onto it).
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub enum LfsBatchOperation {
209 /// The client means to push media (the absent response objects
210 /// carry upload-action mint inputs).
211 Upload,
212 /// The client means to pull media (the present response objects
213 /// carry download-action mint inputs).
214 Download,
215}
216
217impl LfsBatchOperation {
218 /// The wire form's parse (the door's mapping input); anything
219 /// else is not a batch operation.
220 pub fn from_wire(wire: &str) -> Option<Self> {
221 match wire {
222 "upload" => Some(LfsBatchOperation::Upload),
223 "download" => Some(LfsBatchOperation::Download),
224 _ => None,
225 }
226 }
227}
228
229/// One batch request object: the pointer's oid and its mandatory
230/// size (the batch's size is mandatory — LFS clients always send it;
231/// the door refuses the size-less form before this surface).
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct LfsBatchObject {
234 /// The LFS media oid (the raw sha256 of the content — 64 hex, the
235 /// media family's digest; case as the client sent it, the
236 /// validated form is lowercased).
237 pub oid: String,
238 /// The mandatory asserted media size.
239 pub size: u64,
240}
241
242/// The batch/checking request as the door hands it over (post-JSON,
243/// post-auth plumbing — the identity resolved, the operation mapped).
244#[derive(Clone, Debug, PartialEq, Eq)]
245pub struct LfsBatchRequest {
246 /// The batch's operation half.
247 pub operation: LfsBatchOperation,
248 /// The client's declared hash algorithm (`None` = the default).
249 /// Anything but `"sha256"` is refused (the pinned protocol fact).
250 pub hash_algo: Option<String>,
251 /// The requested objects.
252 pub objects: Vec<LfsBatchObject>,
253}
254
255/// The action-grant carrier the response objects hold — the **mint
256/// inputs**, never a URL: the door mints the absolute href and the
257/// per-action `Authorization` from these (front-door-blind — no URL
258/// or door token is formed here).
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct LfsHrefMint {
261 /// The repo id (the ops namespace — the href's repo-path input).
262 pub repo_id: String,
263 /// The object's oid (the href's object-path input).
264 pub oid: String,
265}
266
267/// The per-object action grants (the LFS shape: `upload` rides the
268/// absent objects' answers for an upload batch, `download` the
269/// present ones for a download batch; at most one side is populated
270/// per response object).
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct LfsActions {
273 /// The upload-action mint input (present when the media is
274 /// absent and the caller means to upload).
275 pub upload: Option<LfsHrefMint>,
276 /// The download-action mint input (present when the media is
277 /// present and the caller means to download).
278 pub download: Option<LfsHrefMint>,
279}
280
281/// A per-object error, carried in the response object (the LFS
282/// per-object error shape — the object's `error` field; the HTTP
283/// mapping stays the door's).
284#[derive(Clone, Debug, PartialEq, Eq)]
285pub struct LfsObjectError {
286 /// The LFS error code (403 refused, 404 not found, 422
287 /// malformed / disagreement, 500 server).
288 pub code: u16,
289 /// The stable message text.
290 pub message: &'static str,
291}
292
293impl LfsObjectError {
294 /// The refused-per-grant object.
295 fn refused() -> Self {
296 LfsObjectError {
297 code: 403,
298 message: "refused by grant",
299 }
300 }
301
302 /// The absent-object error (a download batch's miss).
303 fn not_found() -> Self {
304 LfsObjectError {
305 code: 404,
306 message: "object not found",
307 }
308 }
309
310 /// The malformed-oid error (not a 64-lowercase-hex sha256 form).
311 fn malformed() -> Self {
312 LfsObjectError {
313 code: 422,
314 message: "malformed oid",
315 }
316 }
317
318 /// The size-disagreement error (the store entry's length and the
319 /// asserted size disagree — the pointer contradicts the content).
320 fn disagree() -> Self {
321 LfsObjectError {
322 code: 422,
323 message: "size disagreement",
324 }
325 }
326
327 /// The storage-side failure error.
328 fn server() -> Self {
329 LfsObjectError {
330 code: 500,
331 message: "server error",
332 }
333 }
334}
335
336/// One batch response object — the pinned shape `{oid, size,
337/// authenticated}` plus the optional per-object error/actions
338/// branches. `authenticated` is `true` whenever the object is
339/// answered: the request rode the wired ownership provider's grant
340/// (the ops ACL path), so every served action is server-authorized —
341/// the anonymous-public composition (the door's synthesized `anon`
342/// identity on a public repo's read) answers the same way.
343#[derive(Clone, Debug, PartialEq, Eq)]
344pub struct LfsObjectAnswer {
345 /// The object's oid, echoed in the validated lowercase form.
346 pub oid: String,
347 /// The object's asserted size.
348 pub size: u64,
349 /// The server-authorized flag (see the type doc).
350 pub authenticated: bool,
351 /// The per-object error branch (`None` for answered objects).
352 pub error: Option<LfsObjectError>,
353 /// The per-object action branch (`None` when there is nothing to
354 /// do — the already-present object of an upload batch, the
355 /// idempotent re-push's posture).
356 pub actions: Option<LfsActions>,
357}
358
359/// The batch/checking response — the transfer is `basic` and the
360/// served algorithm is `sha256` (the pinned protocol facts; the door
361/// serializes the JSON shape).
362#[derive(Clone, Debug, PartialEq, Eq)]
363pub struct LfsBatchResponse {
364 /// The negotiated transfer adapter (`"basic"`).
365 pub transfer: &'static str,
366 /// The served hash algorithm (`"sha256"` — the only one).
367 pub hash_algo: &'static str,
368 /// One answer per request object, request order.
369 pub objects: Vec<LfsObjectAnswer>,
370}
371
372/// The byte range a download call serves: `[offset, offset + len)`
373/// with `len = None` meaning to the entry's end (the door maps the
374/// HTTP `bytes=a-b` / `bytes=a-` forms onto the explicit count).
375#[derive(Clone, Copy, Debug, PartialEq, Eq)]
376pub struct LfsRange {
377 /// The range's first byte offset.
378 pub offset: u64,
379 /// The range's byte count (`None` = through the entry's end).
380 pub len: Option<u64>,
381}
382
383/// The adapter's error surface (thiserror family; message-only —
384/// the engine source types stay off the surface, the row store's
385/// [`StorageError`](alkgit::StorageError) text is carried). The
386/// whole-batch failures (forbidden, hash-algo) and the transfer-path
387/// failures are errors; per-object failures ride the batch response's
388/// error objects, not this surface.
389#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
390pub enum LfsAdapterError {
391 /// The caller is unauthorized (no identity, or the wired provider
392 /// does not grant the operation's action on the namespace). The
393 /// door's mapping is the 401 / generic denial (unknown ≡
394 /// unauthorized).
395 #[error("forbidden: {0}")]
396 Forbidden(String),
397 /// The batch declared a hash algorithm other than `sha256` (the
398 /// pinned 409 fact).
399 #[error("unsupported hash_algo {0}: the server serves sha256 only")]
400 HashAlgo(String),
401 /// The transfer payload is malformed (a non-oid form).
402 #[error("invalid LFS input: {0}")]
403 Invalid(String),
404 /// The whole download's object exceeds the configured ceiling.
405 #[error("object of {0} bytes exceeds the configured download ceiling")]
406 TooLarge(u64),
407 /// The download's byte range names nothing the entry serves
408 /// (a past-EOF offset — the door's 416).
409 #[error("the requested range is not satisfiable")]
410 NotSatisfiable,
411 /// The storage read or the serving machinery failed.
412 #[error("storage: {0}")]
413 Storage(String),
414 /// The row assertion failed.
415 #[error("lfs rows: {0}")]
416 Rows(String),
417 /// The upload's offer carried no size (the pre-keyed put's
418 /// mandatory offer — the pinned protocol refusal).
419 #[error("size-less offer refused: the pre-keyed put requires the batch's size")]
420 SizelessOffer,
421 /// The upload's body failed its claim: the stream's sha256 is
422 /// not the claimed oid (the put's contract — nothing stored, no
423 /// held pin; killed pre-EOF, zero residue).
424 #[error("mislabeled body: the stream's digest contradicts the claimed oid {0}")]
425 MislabeledBody(String),
426 /// The custody hand-over failed post-commit (the confirm verb
427 /// refused, the mint failed operationally — the put landed; the
428 /// custody question is the operator's).
429 #[error("custody: {0}")]
430 Custody(String),
431 /// The token mint was refused at the registry's admission cap
432 /// (alkblobs ADR-019): the put already committed and verified;
433 /// no token issued, no custody taken — the re-put is
434 /// CAS-idempotent.
435 #[error("mint refused at the token registry's admission cap: {0}")]
436 MintRefused(String),
437}
438
439/// The wired ownership provider — alkgit's registry composed onto the
440/// ops ACL (the batch's authorization posture: lfs.md §1 — the
441/// per-repo grant governs batch access, the ADR-011 posture composed
442/// onto the ops ACL). The alkblobs ops specs gate on `blob-namespace`
443/// resources whose id is the payload's namespace — the repo id; this
444/// provider answers with the same [`authorize`](alkgit::authorize)
445/// policy function the git surface composes: `read` granted (or
446/// anonymous-public — the door's synthesized `anon` identity rides
447/// the public-repo flag here), `write` per the grant monotonicity
448/// (`manage` ⊃ `write` ⊃ `read`). Unknown repos deny by default —
449/// the unknown ≡ unauthorized posture. Served through the cached
450/// read provider's sync resolve (the hot path — no await, no round
451/// trip; a stale cache delays revocation until the next refresh, the
452/// same posture the git serving path takes).
453pub struct LfsGrantProvider {
454 provider: Arc<crate::registry::CachedRegistryProvider>,
455}
456
457impl LfsGrantProvider {
458 /// The provider over a cached read provider (the assembly's —
459 /// [`SubstrateAssembly::provider`](crate::compose::SubstrateAssembly::provider)).
460 pub fn new(provider: Arc<crate::registry::CachedRegistryProvider>) -> Self {
461 LfsGrantProvider { provider }
462 }
463}
464
465impl OwnershipProvider for LfsGrantProvider {
466 fn owns(
467 &self,
468 identity: &Identity,
469 resource_type: &str,
470 resource_id: &str,
471 action: &str,
472 ) -> bool {
473 if resource_type != "blob-namespace" {
474 return false;
475 }
476 let action = match action {
477 "read" => alkgit::AccessAction::Read,
478 "write" => alkgit::AccessAction::Write,
479 _ => return false,
480 };
481 match self.provider.resolve(resource_id) {
482 Some(record) => alkgit::authorize(&record, Some(identity), action),
483 None => false,
484 }
485 }
486
487 fn owned_resources(&self, identity: &Identity, resource_type: &str) -> Vec<String> {
488 if resource_type != "blob-namespace" {
489 return Vec::new();
490 }
491 let snapshot = self.provider.snapshot();
492 let mut owned = Vec::new();
493 for (repo_id, record) in snapshot.iter() {
494 if alkgit::authorize(record, Some(identity), alkgit::AccessAction::Read) {
495 owned.push(repo_id.clone());
496 }
497 }
498 owned.sort();
499 owned
500 }
501
502 fn owns_any(&self, identity: &Identity, resource_type: &str) -> bool {
503 !self.owned_resources(identity, resource_type).is_empty()
504 }
505}
506
507/// The LFS put's converter — the substrate's `PinConverter`
508/// registration act (ADR-022 §4's pinned sentence, realized): the
509/// converter stays what alkblobs pins it to be (sync-only,
510/// failure-free, `Key`-only) and registers **nothing** — the rich
511/// reference metadata lives in alkgit's rows (asserted by this
512/// surface before the confirm), and the entry's store-side
513/// protection is the LFS media liveness source registered at the
514/// composition entry (the fail-safe default), which consults the
515/// rows. The confirm verb demands *a* converter per namespace (an
516/// unbound converter refuses, mapping intact — alkblobs ADR-010 §4);
517/// this is the honest act.
518struct RowsAssertedConverter;
519
520impl PinConverter for RowsAssertedConverter {
521 fn convert(&self, _key: &Key) {}
522}
523
524/// The batch/transfer adapter — the storage-side surface the alkhttp
525/// `lfs` door consumes (ADR-022 §1–3, lfs.md §1–2 — the
526/// adapter/door split is the module doc). Construct over the object
527/// pool, the rows surface, the **wired** ownership provider
528/// (required — see the module doc), and the config knobs.
529pub struct LfsAdapter {
530 ops: Arc<OpsContext>,
531 registry: Arc<OperationRegistry>,
532 env: Arc<LocalOperationEnv>,
533 rows: Arc<dyn LfsRows>,
534 ownership: Arc<dyn OwnershipProvider>,
535 config: LfsAdapterConfig,
536 requests: AtomicU64,
537}
538
539impl LfsAdapter {
540 /// Construct the adapter. The registry assembled here is the ops
541 /// control plane the batch answers over (the POC-L DirectOps
542 /// binding shape): `blobs/stat` through the [`OpsContext`], the
543 /// namespace ACL riding the wired provider — the one op this
544 /// surface registers, so the ops fetch op's own knobs
545 /// (`fetch_max_total_bytes` and friends) stay at their defaults
546 /// and are not this surface's budget: downloads ride
547 /// `read_range` directly under the resolved-span ceiling
548 /// ([`LfsAdapterConfig::max_object_bytes`], enforced adapter-side).
549 /// Fallible on the config's shape (the fallible-constructor
550 /// posture).
551 pub fn new(
552 store: Arc<BlobStore>,
553 rows: Arc<dyn LfsRows>,
554 ownership: Arc<dyn OwnershipProvider>,
555 config: LfsAdapterConfig,
556 ) -> Result<Self, OpenError> {
557 if config.window_bytes == 0 {
558 return Err(OpenError::Invalid(
559 "the LFS adapter's window_bytes must be non-zero".to_owned(),
560 ));
561 }
562 let ops = Arc::new(OpsContext::new(Arc::clone(&store), OpsConfig::default()));
563 let (spec, handler) = stat_pair(Arc::clone(&ops));
564 let registry = Arc::new(
565 OperationRegistryBuilder::new()
566 .with_local(spec, handler, None, None, Capabilities::new())
567 .map_err(|e| OpenError::Invalid(format!("the ops registry assembly: {e}")))?
568 .build(),
569 );
570 let env = Arc::new(LocalOperationEnv::new(Arc::clone(®istry)));
571 Ok(LfsAdapter {
572 ops,
573 registry,
574 env,
575 rows,
576 ownership,
577 config,
578 requests: AtomicU64::new(0),
579 })
580 }
581
582 /// The adapter's config (the door reads the budget knobs).
583 pub fn config(&self) -> &LfsAdapterConfig {
584 &self.config
585 }
586
587 /// The per-request `OperationContext` — the POC-L door's DirectOps
588 /// posture: the caller's identity, the wired ownership provider
589 /// (always `Some` — never the static fallback), the local
590 /// operation env, abort defaults. A request id forms from a
591 /// per-adapter counter (correlation data; the wire never sees it).
592 fn context(&self, identity: Option<Identity>) -> OperationContext {
593 let nanos = SystemTime::now()
594 .duration_since(UNIX_EPOCH)
595 .map(|d| d.as_nanos())
596 .unwrap_or_default();
597 OperationContext {
598 request_id: format!(
599 "lfs-adapter-{}-{nanos}",
600 self.requests.fetch_add(1, Ordering::Relaxed)
601 ),
602 parent_request_id: None,
603 identity,
604 handler_identity: None,
605 forwarded_for: None,
606 capabilities: Capabilities::new(),
607 metadata: std::collections::HashMap::new(),
608 scoped_env: ScopedPeerEnv::empty(),
609 env: Arc::clone(&self.env) as Arc<dyn OperationEnv>,
610 abort_policy: AbortPolicy::default(),
611 deadline: None,
612 internal: false,
613 ownership: Some(Arc::clone(&self.ownership)),
614 }
615 }
616
617 /// The batch/checking answer (lfs.md §1): the formable-key
618 /// existence over the ops control plane (`blobs/stat` through the
619 /// context), the response objects' shapes, and the row assertion
620 /// at check time — every validated object's `(repo_id, oid, size)`
621 /// pair is asserted **before** the probes (the moment the server
622 /// learns the pair; a malformed oid cannot form a row and its
623 /// answer is the per-object 422). The batch's own gates run
624 /// first: no identity, or the wired provider not granting the
625 /// operation's action (`write` for upload batches, `read` for
626 /// download batches), is the forbidden refusal; a declared
627 /// `hash_algo` other than `sha256` is the pinned 409.
628 pub async fn check(
629 &self,
630 identity: Option<&Identity>,
631 repo_id: &str,
632 request: LfsBatchRequest,
633 ) -> Result<LfsBatchResponse, LfsAdapterError> {
634 let identity = require_identity(identity)?;
635 if let Some(algo) = &request.hash_algo {
636 if algo != "sha256" {
637 return Err(LfsAdapterError::HashAlgo(algo.clone()));
638 }
639 }
640 let action = match request.operation {
641 LfsBatchOperation::Upload => "write",
642 LfsBatchOperation::Download => "read",
643 };
644 if !self
645 .ownership
646 .owns(&identity, "blob-namespace", repo_id, action)
647 {
648 return Err(LfsAdapterError::Forbidden(format!(
649 "not granted: blob-namespace/{repo_id} {action}"
650 )));
651 }
652
653 for object in &request.objects {
654 if lfs_media_key(&object.oid).is_ok() {
655 self.rows
656 .assert(repo_id, &lower_hex(&object.oid), object.size)
657 .await
658 .map_err(|e| LfsAdapterError::Rows(e.to_string()))?;
659 }
660 }
661
662 let mut objects = Vec::with_capacity(request.objects.len());
663 for object in &request.objects {
664 let oid = lower_hex(&object.oid);
665 let answer = match lfs_media_key(&oid) {
666 Err(_) => LfsObjectAnswer {
667 oid,
668 size: object.size,
669 authenticated: true,
670 error: Some(LfsObjectError::malformed()),
671 actions: None,
672 },
673 Ok(key) => {
674 self.answers(
675 &identity,
676 repo_id,
677 request.operation,
678 &oid,
679 object.size,
680 &key,
681 )
682 .await
683 }
684 };
685 objects.push(answer);
686 }
687 Ok(LfsBatchResponse {
688 transfer: "basic",
689 hash_algo: "sha256",
690 objects,
691 })
692 }
693
694 /// One validated request object's answer: the stat probe over the
695 /// control plane, then the operation's branch (the mint inputs —
696 /// never URLs — for the to-be-uploaded absent object or the
697 /// downloadable present one, the per-object error shapes).
698 async fn answers(
699 &self,
700 identity: &Identity,
701 repo_id: &str,
702 operation: LfsBatchOperation,
703 oid: &str,
704 size: u64,
705 key: &Key,
706 ) -> LfsObjectAnswer {
707 match self.stat(identity, repo_id, key).await {
708 StatProbe::Missing => match operation {
709 LfsBatchOperation::Upload => LfsObjectAnswer {
710 oid: oid.to_owned(),
711 size,
712 authenticated: true,
713 error: None,
714 actions: Some(LfsActions {
715 upload: Some(LfsHrefMint {
716 repo_id: repo_id.to_owned(),
717 oid: oid.to_owned(),
718 }),
719 download: None,
720 }),
721 },
722 LfsBatchOperation::Download => LfsObjectAnswer {
723 oid: oid.to_owned(),
724 size,
725 authenticated: true,
726 error: Some(LfsObjectError::not_found()),
727 actions: None,
728 },
729 },
730 StatProbe::Length(len) if len == size => match operation {
731 LfsBatchOperation::Upload => LfsObjectAnswer {
732 oid: oid.to_owned(),
733 size,
734 authenticated: true,
735 error: None,
736 actions: None,
737 },
738 LfsBatchOperation::Download => LfsObjectAnswer {
739 oid: oid.to_owned(),
740 size,
741 authenticated: true,
742 error: None,
743 actions: Some(LfsActions {
744 upload: None,
745 download: Some(LfsHrefMint {
746 repo_id: repo_id.to_owned(),
747 oid: oid.to_owned(),
748 }),
749 }),
750 },
751 },
752 StatProbe::Length(_) => LfsObjectAnswer {
753 oid: oid.to_owned(),
754 size,
755 authenticated: true,
756 error: Some(LfsObjectError::disagree()),
757 actions: None,
758 },
759 StatProbe::Forbidden => LfsObjectAnswer {
760 oid: oid.to_owned(),
761 size,
762 authenticated: false,
763 error: Some(LfsObjectError::refused()),
764 actions: None,
765 },
766 StatProbe::Failed => LfsObjectAnswer {
767 oid: oid.to_owned(),
768 size,
769 authenticated: true,
770 error: Some(LfsObjectError::server()),
771 actions: None,
772 },
773 }
774 }
775
776 /// The ops control plane's stat probe (the formable key's
777 /// existence): `blobs/stat` invoked directly on the assembled
778 /// registry through a per-object context — the ACL runs exactly
779 /// as on the wire (the `resource_id_path` selects the namespace).
780 async fn stat(&self, identity: &Identity, namespace: &str, key: &Key) -> StatProbe {
781 let envelope = self
782 .registry
783 .invoke(
784 "blobs/stat",
785 serde_json::json!({
786 "namespace": namespace,
787 "digest": key_hex(key),
788 }),
789 self.context(Some(identity.clone())),
790 )
791 .await;
792 match envelope.result {
793 Ok(payload) => {
794 let len = payload
795 .get("len")
796 .and_then(serde_json::Value::as_u64)
797 .unwrap_or_default();
798 StatProbe::Length(len)
799 }
800 Err(error) if error.code == BLOB_MISSING => StatProbe::Missing,
801 Err(error) if error.code == "FORBIDDEN" => StatProbe::Forbidden,
802 Err(_) => StatProbe::Failed,
803 }
804 }
805
806 /// The upload — the pre-keyed put (ADR-022 §2). The identity and
807 /// the write grant are required (the wired provider; the door
808 /// carries the Basic tolerance). The size is the put's mandatory
809 /// claim: `None` is the size-less refusal. The row is asserted
810 /// first (the moment the upload learns the pair), then the put
811 /// rides the put handler's contract minus the channel: the
812 /// one-read tee over a bounded pipe, the claim cross-check
813 /// pre-EOF (a mislabeled body kills the put — zero residue), then
814 /// the custody hand-over through the `OpsContext` verbs — mint
815 /// and confirm (the fleet form mints and drops the guard; the
816 /// non-fleet form mints custodial and confirms, the POC-L
817 /// attach-reference pattern).
818 pub async fn upload<R>(
819 &self,
820 identity: Option<&Identity>,
821 repo_id: &str,
822 oid: &str,
823 size: Option<u64>,
824 body: R,
825 ) -> Result<(), LfsAdapterError>
826 where
827 R: AsyncRead + Unpin + Send,
828 {
829 let identity = require_identity(identity)?;
830 if !self
831 .ownership
832 .owns(&identity, "blob-namespace", repo_id, "write")
833 {
834 return Err(LfsAdapterError::Forbidden(format!(
835 "not granted: blob-namespace/{repo_id} write"
836 )));
837 }
838 let size = size.ok_or(LfsAdapterError::SizelessOffer)?;
839 let oid = lower_hex(oid);
840 let expected = lfs_media_key(&oid).map_err(|e| LfsAdapterError::Invalid(e.to_string()))?;
841
842 self.rows
843 .assert(repo_id, &oid, size)
844 .await
845 .map_err(|e| LfsAdapterError::Rows(e.to_string()))?;
846
847 let (key, pin) =
848 pre_keyed_put(Arc::clone(self.ops.store()), &expected, &oid, size, body).await?;
849
850 self.hand_over(repo_id, key, pin).await
851 }
852
853 /// The custody hand-over post-commit (the put handler's finish
854 /// arm plus the LFS confirm): fleet — `mint_fleet` over the
855 /// committed row (the owner is the serving instance's id, minted
856 /// against the store's fleet declaration) then an immediate
857 /// `confirm` through [`RowsAssertedConverter`]: the upload is
858 /// complete before the hand-over, so the token's ticket retires
859 /// at the confirmation (the mint boundary's admission cap stays
860 /// live per upload — ADR-022 §5's hostile-loop posture — while a
861 /// dropped unconfirmed ticket would hold an admission slot until
862 /// process end, the wave-9 gate's fix); the fleet pin row's TTL
863 /// remains the durable protection. Non-fleet — `mint_custodial`
864 /// (the registry takes the guard) then `confirm`. A mint refused
865 /// at the cap answers the already-committed put honestly
866 /// ([`LfsAdapterError::MintRefused`]; the re-put is
867 /// CAS-idempotent); an operational mint failure or a refused
868 /// confirm is [`LfsAdapterError::Custody`].
869 async fn hand_over(&self, namespace: &str, key: Key, pin: Pin) -> Result<(), LfsAdapterError> {
870 let refused = |key: &Key| {
871 LfsAdapterError::MintRefused(format!(
872 "no token issued, no custody taken; the committed key {} stands — the re-put is \
873 CAS-idempotent",
874 hex_of(key.digest().bytes()),
875 ))
876 };
877 match self.ops.fleet_owner() {
878 Some(fleet) => match self.ops.mint_fleet(namespace, key.clone(), &fleet) {
879 Ok(token) => {
880 self.ops
881 .bind_converter(namespace, Arc::new(RowsAssertedConverter));
882 if self.ops.confirm(namespace, &token) {
883 drop(pin);
884 Ok(())
885 } else {
886 drop(pin);
887 Err(LfsAdapterError::Custody(
888 "the confirm conversion refused (the mapping stands intact)".to_owned(),
889 ))
890 }
891 }
892 Err(alkblobs::Error::RegistryFull) => Err(refused(&key)),
893 Err(e) => {
894 drop(pin);
895 Err(LfsAdapterError::Custody(e.to_string()))
896 }
897 },
898 None => {
899 self.ops
900 .bind_converter(namespace, Arc::new(RowsAssertedConverter));
901 match self.ops.mint_custodial(namespace, key.clone(), pin) {
902 Ok(token) if self.ops.confirm(namespace, &token) => Ok(()),
903 Ok(_token) => Err(LfsAdapterError::Custody(
904 "the confirm conversion refused (the mapping stands intact)".to_owned(),
905 )),
906 Err(alkblobs::Error::RegistryFull) => Err(refused(&key)),
907 Err(e) => Err(LfsAdapterError::Custody(e.to_string())),
908 }
909 }
910 }
911 }
912
913 /// The download — the ranged fetch (ADR-022 §2): the read grant is
914 /// required (the wired provider), the entry's existence rides
915 /// `stat`, and the served stream verifies per-window slice
916 /// digests mid-flight (the module doc's byte-path section). The
917 /// resolved span is budget-gated by
918 /// [`LfsAdapterConfig::max_object_bytes`] — every form (the
919 /// range-less whole serve, the open-ended resume, any resolved
920 /// span) the adapter will actually produce counts against the
921 /// ceiling; a past-EOF offset is the not-satisfiable refusal (the
922 /// door's 416). A whole-span download additionally verifies the
923 /// served bytes' sha256 against the oid end-to-end at EOF (the
924 /// large tier's reads carry no canonical re-derivation — the
925 /// oid check is this path's job); a partial span's windows carry
926 /// the length + store-interior slice checks only (the module
927 /// doc's verification statement).
928 pub async fn download(
929 &self,
930 identity: Option<&Identity>,
931 repo_id: &str,
932 oid: &str,
933 range: Option<LfsRange>,
934 ) -> Result<LfsDownload, LfsAdapterError> {
935 let identity = require_identity(identity)?;
936 if !self
937 .ownership
938 .owns(&identity, "blob-namespace", repo_id, "read")
939 {
940 return Err(LfsAdapterError::Forbidden(format!(
941 "not granted: blob-namespace/{repo_id} read"
942 )));
943 }
944 let oid = lower_hex(oid);
945 let key = lfs_media_key(&oid).map_err(|e| LfsAdapterError::Invalid(e.to_string()))?;
946 let meta = self
947 .ops
948 .store()
949 .stat(key.key_bytes().as_slice())
950 .await
951 .map_err(|e| LfsAdapterError::Storage(e.to_string()))?;
952 let entry_len = match meta {
953 Some(meta) => meta.len,
954 None => {
955 return Err(LfsAdapterError::Storage(
956 "the media entry is absent".to_owned(),
957 ))
958 }
959 };
960 let (from, until) = match range {
961 None => (0, entry_len),
962 Some(range) => {
963 if range.offset >= entry_len {
964 return Err(LfsAdapterError::NotSatisfiable);
965 }
966 let end = match range.len {
967 Some(len) => range.offset.saturating_add(len).min(entry_len),
968 None => entry_len,
969 };
970 (range.offset, end)
971 }
972 };
973 if until - from > self.config.max_object_bytes {
974 return Err(LfsAdapterError::TooLarge(entry_len));
975 }
976 let whole_span = from == 0 && until == entry_len;
977 Ok(LfsDownload::start(
978 Arc::clone(self.ops.store()),
979 key,
980 from,
981 until,
982 self.config.window_bytes,
983 whole_span,
984 ))
985 }
986}
987
988/// The probe's outcome (the control plane's answer, folded to what
989/// the batch branch consumes).
990enum StatProbe {
991 /// The formed key is absent from the pool (`BLOB_MISSING`).
992 Missing,
993 /// The entry's length (`blobs/stat`'s `len`).
994 Length(u64),
995 /// The ACL refused (a raced revocation mid-batch).
996 Forbidden,
997 /// Any other failure.
998 Failed,
999}
1000
1001/// The lowercase-hex normalization (the validated oid form).
1002fn lower_hex(oid: &str) -> String {
1003 oid.chars().map(|c| c.to_ascii_lowercase()).collect()
1004}
1005
1006/// The digest's hex form over the key's physical bytes (tag-prefixed
1007/// key hex — the ops wire form, ADR-020 §Decision 2).
1008fn key_hex(key: &Key) -> String {
1009 let bytes = key.as_bytes();
1010 let mut hex = String::with_capacity(bytes.len() * 2);
1011 for byte in &bytes {
1012 hex.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
1013 hex.push(char::from_digit((byte & 0xf) as u32, 16).unwrap_or('0'));
1014 }
1015 hex
1016}
1017
1018/// The byte-slice's hex form (the digest comparisons).
1019fn hex_of(bytes: &[u8]) -> String {
1020 let mut hex = String::with_capacity(bytes.len() * 2);
1021 for byte in bytes {
1022 hex.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
1023 hex.push(char::from_digit((byte & 0xf) as u32, 16).unwrap_or('0'));
1024 }
1025 hex
1026}
1027
1028/// The identity requirement — the adapter never synthesizes one (the
1029/// `anon` composition is the door's; lfs.md §1).
1030fn require_identity(identity: Option<&Identity>) -> Result<Identity, LfsAdapterError> {
1031 identity
1032 .cloned()
1033 .ok_or_else(|| LfsAdapterError::Forbidden("authentication required".to_owned()))
1034}
1035
1036/// The pre-keyed put's machinery — the put handler's contract minus
1037/// the channel, replicated in-process: the facade put consumes a
1038/// bounded pipe whose feed is the caller body's one read; the sha256
1039/// pass rides the same read; a claim the derivation contradicts
1040/// kills the put pre-EOF (the kill's ordering — flag armed ahead of
1041/// the sender's drop — matches the put handler's pipe writer). The
1042/// facade arm is `put_as(GitObjectSha256, Some(len), …)` — the
1043/// identity family's derivation is the raw content's hash (the oid,
1044/// byte-exact), so the returned key equals the formed key on an
1045/// honest body and the cross-check is the same one-stream pass.
1046async fn pre_keyed_put<R>(
1047 store: Arc<BlobStore>,
1048 expected: &Key,
1049 oid: &str,
1050 size: u64,
1051 body: R,
1052) -> Result<(Key, Pin), LfsAdapterError>
1053where
1054 R: AsyncRead + Unpin + Send,
1055{
1056 const PIPE_BOUND: usize = 64;
1057 const READ_CHUNK: usize = 16 * 1024;
1058
1059 enum StreamEnd {
1060 Eof,
1061 BodyFailed(tokio::io::Error),
1062 PutEnded,
1063 }
1064
1065 let fail = Arc::new(AtomicBool::new(false));
1066 let (tx, rx) = mpsc::channel::<Arc<Vec<u8>>>(PIPE_BOUND);
1067 let put = {
1068 let store = Arc::clone(&store);
1069 let fail = Arc::clone(&fail);
1070 tokio::spawn(async move {
1071 store
1072 .put_as(
1073 alkblobs::Algorithm::GitObjectSha256,
1074 Some(size),
1075 PutPipe {
1076 rx,
1077 fail,
1078 buffer: None,
1079 offset: 0,
1080 },
1081 )
1082 .await
1083 })
1084 };
1085
1086 let mut body = body;
1087 let mut hasher = Sha256::new();
1088 let mut window = vec![0u8; READ_CHUNK];
1089 let end = loop {
1090 if put.is_finished() {
1091 break StreamEnd::PutEnded;
1092 }
1093 match body.read(&mut window).await {
1094 Ok(0) => break StreamEnd::Eof,
1095 Ok(n) => {
1096 hasher.update(&window[..n]);
1097 if tx.send(Arc::new(window[..n].to_vec())).await.is_err() {
1098 break StreamEnd::PutEnded;
1099 }
1100 }
1101 Err(e) => break StreamEnd::BodyFailed(e),
1102 }
1103 };
1104
1105 match end {
1106 StreamEnd::Eof => {
1107 let derived = hex_of(&hasher.finalize());
1108 if derived != oid {
1109 fail.store(true, Ordering::Release);
1110 drop(tx);
1111 let _ = put.await;
1112 return Err(LfsAdapterError::MislabeledBody(format!(
1113 "no stored content, no held pin — {oid}"
1114 )));
1115 }
1116 drop(tx);
1117 match put.await {
1118 Ok(Ok(landing)) => {
1119 if landing.0 != *expected {
1120 return Err(LfsAdapterError::MislabeledBody(oid.to_owned()));
1121 }
1122 Ok(landing)
1123 }
1124 Ok(Err(e)) => Err(LfsAdapterError::Storage(e.to_string())),
1125 Err(e) => Err(LfsAdapterError::Storage(format!(
1126 "the put's store task failed: {e}"
1127 ))),
1128 }
1129 }
1130 StreamEnd::BodyFailed(e) => {
1131 fail.store(true, Ordering::Release);
1132 drop(tx);
1133 let _ = put.await;
1134 Err(LfsAdapterError::Storage(format!(
1135 "the upload body failed mid-stream: {e}"
1136 )))
1137 }
1138 StreamEnd::PutEnded => match put.await {
1139 Ok(Ok(_)) => Err(LfsAdapterError::Storage(
1140 "the put's store arm completed against a torn stream".to_owned(),
1141 )),
1142 Ok(Err(e)) => Err(LfsAdapterError::Storage(e.to_string())),
1143 Err(e) => Err(LfsAdapterError::Storage(format!(
1144 "the put's store task failed: {e}"
1145 ))),
1146 },
1147 }
1148}
1149
1150/// The put pipe's reader half (the put handler's `PutPipeReader`
1151/// shape): the facade put consumes the tee's feed; the kill flag
1152/// arms the next read into an error — the facade's stream-error path
1153/// (no commit, zero residue).
1154struct PutPipe {
1155 rx: mpsc::Receiver<Arc<Vec<u8>>>,
1156 fail: Arc<AtomicBool>,
1157 buffer: Option<Arc<Vec<u8>>>,
1158 offset: usize,
1159}
1160
1161impl AsyncRead for PutPipe {
1162 fn poll_read(
1163 self: std::pin::Pin<&mut Self>,
1164 cx: &mut PollContext<'_>,
1165 buf: &mut ReadBuf<'_>,
1166 ) -> Poll<tokio::io::Result<()>> {
1167 let this = self.get_mut();
1168 loop {
1169 if this.fail.load(Ordering::Acquire) {
1170 return Poll::Ready(Err(tokio::io::Error::new(
1171 tokio::io::ErrorKind::BrokenPipe,
1172 "the put's feed failed pre-commit (the stream errored)",
1173 )));
1174 }
1175 if let Some(chunk) = &this.buffer {
1176 if this.offset < chunk.len() {
1177 let n = (chunk.len() - this.offset).min(buf.remaining());
1178 buf.put_slice(&chunk[this.offset..this.offset + n]);
1179 this.offset += n;
1180 return Poll::Ready(Ok(()));
1181 }
1182 this.buffer = None;
1183 this.offset = 0;
1184 }
1185 match this.rx.poll_recv(cx) {
1186 Poll::Ready(Some(chunk)) => {
1187 this.buffer = Some(chunk);
1188 this.offset = 0;
1189 }
1190 Poll::Ready(None) => return Poll::Ready(Ok(())),
1191 Poll::Pending => return Poll::Pending,
1192 }
1193 }
1194 }
1195}
1196
1197/// The download's verified-window stream — an [`AsyncRead`] fed by a
1198/// pump that splits the requested range at the configured window
1199/// size and yields each window only after its mid-flight slice-digest
1200/// check passes (the module doc's byte-path section) — ADR-022 §2
1201/// as amended (the download byte path's verification posture). A failed
1202/// window check or a failed read surfaces as an io error at the
1203/// yield point (already-delivered windows stand; the caller aborts
1204/// and re-fetches). The pump ends at EOF; a zero-length whole read
1205/// is an empty stream (the entry's zero-length entry served).
1206pub struct LfsDownload {
1207 rx: mpsc::Receiver<tokio::io::Result<Vec<u8>>>,
1208 buffer: Option<Vec<u8>>,
1209 position: usize,
1210}
1211
1212impl LfsDownload {
1213 /// Start the pump (the download call's tail). The channel bound
1214 /// is two windows — the store's pacing backpressures the
1215 /// consumer through the pipe (the same discipline as the put's
1216 /// bounded pipe, read-swapped). A whole-span download
1217 /// (`whole_span`) rides the end-to-end oid digest check.
1218 fn start(
1219 store: Arc<BlobStore>,
1220 key: Key,
1221 from: u64,
1222 until: u64,
1223 window_bytes: u64,
1224 whole_span: bool,
1225 ) -> Self {
1226 let (tx, rx) = mpsc::channel(2);
1227 tokio::spawn(verified_windows(
1228 store,
1229 key,
1230 from,
1231 until,
1232 window_bytes,
1233 whole_span,
1234 tx,
1235 ));
1236 LfsDownload {
1237 rx,
1238 buffer: None,
1239 position: 0,
1240 }
1241 }
1242}
1243
1244impl AsyncRead for LfsDownload {
1245 fn poll_read(
1246 self: std::pin::Pin<&mut Self>,
1247 cx: &mut PollContext<'_>,
1248 buf: &mut ReadBuf<'_>,
1249 ) -> Poll<tokio::io::Result<()>> {
1250 let this = self.get_mut();
1251 loop {
1252 if let Some(chunk) = this.buffer.as_ref() {
1253 if this.position < chunk.len() {
1254 let n = (chunk.len() - this.position).min(buf.remaining());
1255 buf.put_slice(&chunk[this.position..this.position + n]);
1256 this.position += n;
1257 return Poll::Ready(Ok(()));
1258 }
1259 this.buffer = None;
1260 this.position = 0;
1261 }
1262 match this.rx.poll_recv(cx) {
1263 Poll::Ready(Some(Ok(chunk))) => {
1264 this.buffer = Some(chunk);
1265 this.position = 0;
1266 }
1267 Poll::Ready(Some(Err(e))) => return Poll::Ready(Err(e)),
1268 Poll::Ready(None) => return Poll::Ready(Ok(())),
1269 Poll::Pending => return Poll::Pending,
1270 }
1271 }
1272 }
1273}
1274
1275/// The download pump: one `read_range` per window (clamped at the
1276/// requested end), the slice digest checked against the sha256 of
1277/// exactly the returned bytes mid-flight, then the window handed on.
1278/// A whole-span download (`whole_span`) additionally accumulates the
1279/// windows' bytes into one sha256 pass and checks the finished
1280/// digest against the key's derivation digest **before the last
1281/// window yields** — the identity family's digest is the raw
1282/// content's hash, so the comparison is the end-to-end oid
1283/// verification over the large tier's unverified reads (the
1284/// module doc's verification statement; the wave-9 gate's fix —
1285/// the per-window slice pair was checked against the same in-process
1286/// bytes and could not catch a corrupted entry).
1287async fn verified_windows(
1288 store: Arc<BlobStore>,
1289 key: Key,
1290 from: u64,
1291 until: u64,
1292 window_bytes: u64,
1293 whole_span: bool,
1294 tx: mpsc::Sender<tokio::io::Result<Vec<u8>>>,
1295) {
1296 let key_bytes = key.key_bytes();
1297 let expected = whole_span.then(|| key.digest().bytes().to_vec());
1298 let mut hasher = whole_span.then(Sha256::new);
1299 let mut offset = from;
1300 while offset < until {
1301 let window_len = window_bytes.min(until - offset);
1302 let last = offset + window_len == until;
1303 match store
1304 .read_range(key_bytes.as_slice(), offset, window_len)
1305 .await
1306 {
1307 Ok((slice, digest)) => {
1308 let hash = Sha256::digest(&slice);
1309 if slice.len() as u64 != window_len || hex_of(hash.as_slice()) != hex_of(&digest) {
1310 let _ = tx
1311 .send(Err(tokio::io::Error::other(
1312 "the verified window's slice digest mismatched mid-flight",
1313 )))
1314 .await;
1315 return;
1316 }
1317 if let Some(h) = hasher.as_mut() {
1318 h.update(&slice);
1319 }
1320 if last && whole_span {
1321 if let (Some(h), Some(expected_bytes)) = (hasher.as_ref(), expected.as_deref())
1322 {
1323 let digest_now = h.clone().finalize();
1324 if digest_now.as_slice() != expected_bytes {
1325 let _ = tx
1326 .send(Err(tokio::io::Error::other(
1327 "the served media's whole-content digest mismatched the oid",
1328 )))
1329 .await;
1330 return;
1331 }
1332 }
1333 }
1334 offset += window_len;
1335 if tx.send(Ok(slice)).await.is_err() {
1336 return;
1337 }
1338 }
1339 Err(e) => {
1340 let _ = tx
1341 .send(Err(tokio::io::Error::other(format!(
1342 "the media read failed: {e}"
1343 ))))
1344 .await;
1345 return;
1346 }
1347 }
1348 }
1349}
1350
1351#[cfg(test)]
1352mod battery {
1353 use std::collections::HashMap;
1354
1355 use tokio::io::AsyncReadExt;
1356
1357 use alkgit::{AccessAction, RepoRecord, Visibility};
1358
1359 use super::*;
1360 use crate::registry::{CachedRegistryProvider, RegistryRow};
1361
1362 fn oid_of(content: &[u8]) -> String {
1363 let digest = Sha256::digest(content);
1364 hex_of(digest.as_slice())
1365 }
1366
1367 fn identity(id: &str) -> Identity {
1368 Identity {
1369 id: id.to_owned(),
1370 scopes: Vec::new(),
1371 resources: HashMap::new(),
1372 }
1373 }
1374
1375 /// An identity carrying a **static** `Identity.resources` claim on
1376 /// the ops resource — the shape the gateway-gap regression pins
1377 /// (admitted only if the wired provider grants; the static path
1378 /// is never consulted through the adapter).
1379 fn identity_with_static_claim(id: &str) -> Identity {
1380 let mut resources = HashMap::new();
1381 resources.insert(
1382 "blob-namespace".to_owned(),
1383 vec!["read".to_owned(), "write".to_owned()],
1384 );
1385 Identity {
1386 id: id.to_owned(),
1387 scopes: Vec::new(),
1388 resources,
1389 }
1390 }
1391
1392 fn record(
1393 repo_id: &str,
1394 visibility: Visibility,
1395 grants: &[(&str, &[AccessAction])],
1396 ) -> RepoRecord {
1397 RepoRecord {
1398 repo_id: repo_id.to_owned(),
1399 visibility,
1400 grants: grants
1401 .iter()
1402 .map(|(id, actions)| ((*id).to_owned(), actions.to_vec()))
1403 .collect(),
1404 }
1405 }
1406
1407 fn registry_provider() -> Arc<CachedRegistryProvider> {
1408 let provider = Arc::new(CachedRegistryProvider::new());
1409 provider.install(vec![
1410 RegistryRow {
1411 record: record(
1412 "owner/granted",
1413 Visibility::Private,
1414 &[("u-writer", &[AccessAction::Read, AccessAction::Write])],
1415 ),
1416 seq: 1,
1417 },
1418 RegistryRow {
1419 record: record(
1420 "owner/readonly",
1421 Visibility::Private,
1422 &[
1423 ("u-writer", &[AccessAction::Read, AccessAction::Write]),
1424 ("u-reader", &[AccessAction::Read]),
1425 ],
1426 ),
1427 seq: 2,
1428 },
1429 RegistryRow {
1430 record: record(
1431 "owner/public",
1432 Visibility::Public,
1433 &[("u-writer", &[AccessAction::Read, AccessAction::Write])],
1434 ),
1435 seq: 3,
1436 },
1437 RegistryRow {
1438 record: record("owner/closed", Visibility::Private, &[]),
1439 seq: 4,
1440 },
1441 ]);
1442 provider
1443 }
1444
1445 fn adapter(
1446 provider: Arc<dyn OwnershipProvider>,
1447 config: LfsAdapterConfig,
1448 ) -> (LfsAdapter, Arc<BlobStore>, Arc<crate::lfs::InMemoryLfsRows>) {
1449 let rows = Arc::new(crate::lfs::InMemoryLfsRows::new());
1450 let store = Arc::new(BlobStore::mem_only());
1451 let adapter = LfsAdapter::new(Arc::clone(&store), rows.clone(), provider, config).unwrap();
1452 (adapter, store, rows)
1453 }
1454
1455 fn grant_provider() -> Arc<dyn OwnershipProvider> {
1456 Arc::new(LfsGrantProvider::new(registry_provider()))
1457 }
1458
1459 fn object(oid: &str, size: u64) -> LfsBatchObject {
1460 LfsBatchObject {
1461 oid: oid.to_owned(),
1462 size,
1463 }
1464 }
1465
1466 fn upload_request(objects: Vec<LfsBatchObject>) -> LfsBatchRequest {
1467 LfsBatchRequest {
1468 operation: LfsBatchOperation::Upload,
1469 hash_algo: None,
1470 objects,
1471 }
1472 }
1473
1474 fn download_request(objects: Vec<LfsBatchObject>) -> LfsBatchRequest {
1475 LfsBatchRequest {
1476 operation: LfsBatchOperation::Download,
1477 hash_algo: None,
1478 objects,
1479 }
1480 }
1481
1482 async fn upload_media(
1483 adapter: &LfsAdapter,
1484 repo_id: &str,
1485 content: &[u8],
1486 ) -> Result<(), LfsAdapterError> {
1487 adapter
1488 .upload(
1489 Some(&identity("u-writer")),
1490 repo_id,
1491 &oid_of(content),
1492 Some(content.len() as u64),
1493 content,
1494 )
1495 .await
1496 }
1497
1498 async fn read_all(mut download: LfsDownload) -> Vec<u8> {
1499 let mut out = Vec::new();
1500 download
1501 .read_to_end(&mut out)
1502 .await
1503 .expect("the download reads");
1504 out
1505 }
1506
1507 async fn download_whole(adapter: &LfsAdapter, repo_id: &str, oid: &str) -> Vec<u8> {
1508 read_all(
1509 adapter
1510 .download(Some(&identity("u-writer")), repo_id, oid, None)
1511 .await
1512 .unwrap(),
1513 )
1514 .await
1515 }
1516
1517 /// The writer identity's batch upload: the two absent objects
1518 /// answer with upload mint inputs (no URLs — the inputs are the
1519 /// repo/oid pair), the rows are asserted at check time, the byte
1520 /// path stores exact bytes, the re-push answers no actions, the
1521 /// dedup re-put is idempotent, and the custody consumes the
1522 /// minted mapping at confirm.
1523 #[tokio::test]
1524 async fn the_upload_batch_and_the_pre_keyed_put() {
1525 let (adapter, store, rows) = adapter(grant_provider(), LfsAdapterConfig::default());
1526 let writer = identity("u-writer");
1527 let content_a = b"the first LFS media\n".to_vec();
1528 let content_b = b"the second LFS media\n".to_vec();
1529 let (oid_a, oid_b) = (oid_of(&content_a), oid_of(&content_b));
1530
1531 let answer = adapter
1532 .check(
1533 Some(&writer),
1534 "owner/granted",
1535 upload_request(vec![object(&oid_a, content_a.len() as u64)]),
1536 )
1537 .await
1538 .unwrap();
1539 assert_eq!(answer.transfer, "basic");
1540 assert_eq!(answer.hash_algo, "sha256");
1541 let first = &answer.objects[0];
1542 assert_eq!(first.oid, oid_a);
1543 assert!(first.authenticated);
1544 assert!(first.error.is_none());
1545 let mint = first
1546 .actions
1547 .as_ref()
1548 .and_then(|a| a.upload.as_ref())
1549 .expect("the upload mint input");
1550 assert_eq!(mint.repo_id, "owner/granted");
1551 assert_eq!(mint.oid, oid_a);
1552 assert!(first
1553 .actions
1554 .as_ref()
1555 .and_then(|a| a.download.as_ref())
1556 .is_none());
1557 // the row asserted at check time (the moment the server learns the pair)
1558 let asserted = rows.repo_rows("owner/granted").await.unwrap();
1559 assert_eq!(
1560 asserted,
1561 vec![crate::lfs::LfsRow {
1562 repo_id: "owner/granted".to_owned(),
1563 oid: oid_a.clone(),
1564 size: content_a.len() as u64,
1565 refcount: 1,
1566 }]
1567 );
1568
1569 // the byte path: exact bytes in, exact bytes out.
1570 upload_media(&adapter, "owner/granted", &content_a)
1571 .await
1572 .unwrap();
1573 assert_eq!(
1574 download_whole(&adapter, "owner/granted", &oid_a).await,
1575 content_a
1576 );
1577 let stat = rows.repo_rows("owner/granted").await.unwrap();
1578 assert_eq!(
1579 stat.iter().find(|r| r.oid == oid_a).unwrap().size,
1580 content_a.len() as u64,
1581 "the upload refreshed the assertion"
1582 );
1583
1584 // the re-push: the present object answers no actions (the
1585 // idempotent posture) — and the batch with both objects
1586 // still mints the absent one.
1587 let answer = adapter
1588 .check(
1589 Some(&writer),
1590 "owner/granted",
1591 upload_request(vec![
1592 object(&oid_a, content_a.len() as u64),
1593 object(&oid_b, content_b.len() as u64),
1594 ]),
1595 )
1596 .await
1597 .unwrap();
1598 assert!(
1599 answer.objects[0].actions.is_none(),
1600 "the present object: nothing to do"
1601 );
1602 assert!(answer.objects[0].error.is_none());
1603 assert!(
1604 answer.objects[1]
1605 .actions
1606 .as_ref()
1607 .and_then(|a| a.upload.as_ref())
1608 .is_some(),
1609 "the absent object still offers the upload mint"
1610 );
1611
1612 // the dedup re-put lands (the CAS-idempotent posture).
1613 upload_media(&adapter, "owner/granted", &content_a)
1614 .await
1615 .unwrap();
1616
1617 // the confirm retired the minted mapping (the custody consumed;
1618 // the entry stands, protected by the registered rows source).
1619 assert_eq!(adapter.ops.tokens().len(), 0);
1620 let key = crate::liveness::lfs_media_key(&oid_a).unwrap();
1621 assert!(
1622 store
1623 .stat(key.key_bytes().as_slice())
1624 .await
1625 .unwrap()
1626 .is_some(),
1627 "the committed content stands post-confirm"
1628 );
1629 }
1630
1631 /// The download batch's shapes: the present object answers the
1632 /// download mint input, the absent object answers the 404 error
1633 /// object, the size-disagreeing request answers the 422, and the
1634 /// anonymous-public read composes through the door's synthesized
1635 /// `anon` identity (read-only on the public repo).
1636 #[tokio::test]
1637 async fn the_download_batch_shapes() {
1638 let (adapter, _store, _rows) = adapter(grant_provider(), LfsAdapterConfig::default());
1639 let content = b"the downloadable media\n".to_vec();
1640 let oid = oid_of(&content);
1641 upload_media(&adapter, "owner/public", &content)
1642 .await
1643 .unwrap();
1644
1645 let answer = adapter
1646 .check(
1647 Some(&identity("anon")),
1648 "owner/public",
1649 download_request(vec![
1650 object(&oid, content.len() as u64),
1651 object(&oid_of(b"never stored"), 10),
1652 ]),
1653 )
1654 .await
1655 .unwrap();
1656 let present = &answer.objects[0];
1657 assert!(present.authenticated);
1658 assert!(present.error.is_none());
1659 let mint = present
1660 .actions
1661 .as_ref()
1662 .and_then(|a| a.download.as_ref())
1663 .expect("the download mint input");
1664 assert_eq!(mint.repo_id, "owner/public");
1665 assert_eq!(mint.oid, oid);
1666
1667 let absent = &answer.objects[1];
1668 assert_eq!(absent.error.as_ref().unwrap().code, 404);
1669 assert!(absent.actions.is_none());
1670
1671 // the anonymous upload batch is refused (push is always
1672 // authenticated) and the anonymous read of a private repo is
1673 // refused (unknown ≡ unauthorized).
1674 assert!(matches!(
1675 adapter
1676 .check(
1677 Some(&identity("anon")),
1678 "owner/public",
1679 upload_request(vec![object(&oid, 10)])
1680 )
1681 .await,
1682 Err(LfsAdapterError::Forbidden(_))
1683 ));
1684 assert!(matches!(
1685 adapter
1686 .check(
1687 Some(&identity("anon")),
1688 "owner/closed",
1689 download_request(vec![object(&oid, 10)])
1690 )
1691 .await,
1692 Err(LfsAdapterError::Forbidden(_))
1693 ));
1694
1695 // the size disagreement (the pointer's size contradicts the
1696 // stored content's length) answers the 422 error object.
1697 let answer = adapter
1698 .check(
1699 Some(&identity("u-writer")),
1700 "owner/granted",
1701 download_request(vec![object(&oid, content.len() as u64 + 5)]),
1702 )
1703 .await
1704 .unwrap();
1705 assert_eq!(answer.objects[0].error.as_ref().unwrap().code, 422);
1706 assert_eq!(
1707 answer.objects[0].error.as_ref().unwrap().message,
1708 "size disagreement"
1709 );
1710 }
1711
1712 /// The pinned protocol gates: `hash_algo` serves sha256 only
1713 /// (the 409 fact) and the size-less upload offer is refused.
1714 #[tokio::test]
1715 async fn the_hash_algo_gate_and_the_sizeless_refusal() {
1716 let (adapter, _store, rows) = adapter(grant_provider(), LfsAdapterConfig::default());
1717 let writer = identity("u-writer");
1718 let oid = oid_of(b"gate media");
1719
1720 let mut request = upload_request(vec![object(&oid, 10)]);
1721 request.hash_algo = Some("sha1".to_owned());
1722 assert!(
1723 matches!(
1724 adapter.check(Some(&writer), "owner/granted", request).await,
1725 Err(LfsAdapterError::HashAlgo(algo)) if algo == "sha1"
1726 ),
1727 "a non-sha256 batch is the pinned 409"
1728 );
1729 assert!(
1730 rows.repo_rows("owner/granted").await.unwrap().is_empty(),
1731 "the refused batch asserted nothing"
1732 );
1733
1734 // the default algorithm (`None`) and the explicit sha256 pass;
1735 // their checks assert the pair (the row assertion's own contract).
1736 let mut request = upload_request(vec![object(&oid, 10)]);
1737 request.hash_algo = Some("sha256".to_owned());
1738 adapter
1739 .check(Some(&writer), "owner/granted", request)
1740 .await
1741 .unwrap();
1742 adapter
1743 .check(
1744 Some(&writer),
1745 "owner/granted",
1746 upload_request(vec![object(&oid, 10)]),
1747 )
1748 .await
1749 .unwrap();
1750 assert_eq!(rows.repo_rows("owner/granted").await.unwrap().len(), 1);
1751
1752 assert!(
1753 matches!(
1754 adapter
1755 .upload(Some(&writer), "owner/granted", &oid, None, &b"media"[..])
1756 .await,
1757 Err(LfsAdapterError::SizelessOffer)
1758 ),
1759 "the size-less offer is a protocol error"
1760 );
1761 assert_eq!(
1762 rows.repo_rows("owner/granted").await.unwrap().len(),
1763 1,
1764 "the size-less refusal asserted nothing"
1765 );
1766 }
1767
1768 /// The mislabeled body: the put's contract — the claim
1769 /// contradicted by the derivation kills the put pre-EOF (zero
1770 /// residue: no stored content, no held pin), the row assertion
1771 /// stands, and a corrected re-upload lands.
1772 #[tokio::test]
1773 async fn the_mislabeled_body_refusal() {
1774 let (adapter, store, rows) = adapter(grant_provider(), LfsAdapterConfig::default());
1775 let claimed = oid_of(b"the honest content");
1776 let honest = b"the honest content".to_vec();
1777
1778 upload_media(&adapter, "owner/granted", &honest)
1779 .await
1780 .unwrap();
1781 let answer = adapter
1782 .download(Some(&identity("u-writer")), "owner/granted", &claimed, None)
1783 .await
1784 .unwrap();
1785 assert_eq!(read_all(answer).await, honest);
1786
1787 // a body whose sha256 is not the claimed oid — the put kills
1788 // pre-EOF and nothing stores.
1789 let bad = b"the dishonest content".to_vec();
1790 adapter
1791 .upload(
1792 Some(&identity("u-writer")),
1793 "owner/granted",
1794 &claimed,
1795 Some(bad.len() as u64),
1796 bad.as_slice(),
1797 )
1798 .await
1799 .unwrap_err();
1800
1801 let key = crate::liveness::lfs_media_key(&claimed).unwrap();
1802 let key_borrow = key.key_bytes();
1803 assert_eq!(
1804 store.stat(&key_borrow).await.unwrap().unwrap().len,
1805 honest.len() as u64,
1806 "the honest entry is the committed one — nothing mislabeled landed"
1807 );
1808 let list = store.list().await.unwrap();
1809 assert_eq!(list.len(), 1, "the kill left zero residue");
1810 assert_eq!(
1811 rows.repo_rows("owner/granted").await.unwrap()[0].oid,
1812 claimed
1813 );
1814 assert_eq!(
1815 adapter.ops.tokens().len(),
1816 0,
1817 "no held pin, no mapping left"
1818 );
1819
1820 // a short body against the offer's len — the facade's own
1821 // known-length refusal (zero residue), the put contract's
1822 // inherited posture.
1823 adapter
1824 .upload(
1825 Some(&identity("u-writer")),
1826 "owner/granted",
1827 &oid_of(&bad),
1828 Some(bad.len() as u64 + 10),
1829 &bad[..bad.len() - 5],
1830 )
1831 .await
1832 .unwrap_err();
1833 assert_eq!(store.list().await.unwrap().len(), 1);
1834 }
1835
1836 /// The ranged download: windows split at the configured boundary,
1837 /// each verified mid-flight, byte-exact over the whole and every
1838 /// slice; the past-EOF offset is the not-satisfiable refusal and
1839 /// the whole download honors the object-size ceiling.
1840 #[tokio::test]
1841 async fn the_ranged_download_splits_and_verifies() {
1842 let config = LfsAdapterConfig {
1843 window_bytes: 1024,
1844 max_object_bytes: 8 * 1024,
1845 };
1846 let (adapter, _store, _rows) = adapter(grant_provider(), config);
1847 let mut content = Vec::new();
1848 for i in 0..100u32 {
1849 content.extend_from_slice(format!("{i:064x}").as_bytes());
1850 }
1851 let oid = oid_of(&content);
1852 upload_media(&adapter, "owner/granted", &content)
1853 .await
1854 .unwrap();
1855
1856 // whole download (over the window boundary — the adapter's
1857 // window arithmetic; byte-exact across the verified windows).
1858 assert_eq!(
1859 download_whole(&adapter, "owner/granted", &oid).await,
1860 content
1861 );
1862
1863 // a slice, byte-exact.
1864 let slice = adapter
1865 .download(
1866 Some(&identity("u-writer")),
1867 "owner/granted",
1868 &oid,
1869 Some(LfsRange {
1870 offset: 500,
1871 len: Some(1000),
1872 }),
1873 )
1874 .await
1875 .unwrap();
1876 assert_eq!(read_all(slice).await, content[500..1500]);
1877
1878 // an open-ended slice (to the end).
1879 let tail = adapter
1880 .download(
1881 Some(&identity("u-writer")),
1882 "owner/granted",
1883 &oid,
1884 Some(LfsRange {
1885 offset: content.len() as u64 - 100,
1886 len: None,
1887 }),
1888 )
1889 .await
1890 .unwrap();
1891 assert_eq!(read_all(tail).await, content[content.len() - 100..]);
1892
1893 // past-EOF offset: the not-satisfiable refusal.
1894 assert!(matches!(
1895 adapter
1896 .download(
1897 Some(&identity("u-writer")),
1898 "owner/granted",
1899 &oid,
1900 Some(LfsRange {
1901 offset: content.len() as u64 + 1,
1902 len: None,
1903 })
1904 )
1905 .await,
1906 Err(LfsAdapterError::NotSatisfiable)
1907 ));
1908
1909 // a whole download over the ceiling is refused; the
1910 // full-span ranged form of the same object is the same span
1911 // and refuses too (the ceiling counts the resolved span, not
1912 // the request's header form); a partial span under the
1913 // ceiling rides the window budget.
1914 let big = vec![0u8; 10 * 1024];
1915 let big_oid = oid_of(&big);
1916 upload_media(&adapter, "owner/granted", &big).await.unwrap();
1917 assert!(matches!(
1918 adapter
1919 .download(Some(&identity("u-writer")), "owner/granted", &big_oid, None)
1920 .await,
1921 Err(LfsAdapterError::TooLarge(_))
1922 ));
1923 assert!(matches!(
1924 adapter
1925 .download(
1926 Some(&identity("u-writer")),
1927 "owner/granted",
1928 &big_oid,
1929 Some(LfsRange {
1930 offset: 0,
1931 len: Some(10 * 1024),
1932 }),
1933 )
1934 .await,
1935 Err(LfsAdapterError::TooLarge(_))
1936 ));
1937 let ranged = adapter
1938 .download(
1939 Some(&identity("u-writer")),
1940 "owner/granted",
1941 &big_oid,
1942 Some(LfsRange {
1943 offset: 0,
1944 len: Some(4 * 1024),
1945 }),
1946 )
1947 .await
1948 .unwrap();
1949 assert_eq!(read_all(ranged).await, big[..4 * 1024]);
1950
1951 // the reader identity rides the read grant; the unknown oid
1952 // is the absent-entry refusal.
1953 assert!(matches!(
1954 adapter
1955 .download(
1956 Some(&identity("u-writer")),
1957 "owner/granted",
1958 &oid_of(b"absent"),
1959 None
1960 )
1961 .await,
1962 Err(LfsAdapterError::Storage(_))
1963 ));
1964 }
1965
1966 /// The wired-provider posture (the POC-L gateway-gap regression,
1967 /// re-landed): the static `Identity.resources` fallback is never
1968 /// consulted through the adapter — a claim-carrying identity is
1969 /// admitted only through the wired provider's live grants; the
1970 /// read-only grantee downloads but never uploads; the grant
1971 /// monotonicity (manage ⊃ write ⊃ read) and the unknown-repo
1972 /// denial ride the same `authorize` policy the git surface
1973 /// composes.
1974 #[tokio::test]
1975 async fn the_wired_provider_posture() {
1976 let provider = grant_provider();
1977 let (adapter, _store, _rows) = adapter(provider.clone(), LfsAdapterConfig::default());
1978 let content = b"the gated media\n".to_vec();
1979 let oid = oid_of(&content);
1980
1981 // no identity: the adapter never synthesizes one.
1982 assert!(matches!(
1983 adapter
1984 .check(
1985 None,
1986 "owner/granted",
1987 download_request(vec![object(&oid, 10)])
1988 )
1989 .await,
1990 Err(LfsAdapterError::Forbidden(_))
1991 ));
1992
1993 // the unrecognized identity: refused everywhere, regardless
1994 // of a static resources claim (the fallback is unreachable —
1995 // the contexts always wire the provider).
1996 for identity in [identity("u-ghost"), identity_with_static_claim("u-ghost")] {
1997 assert!(matches!(
1998 adapter
1999 .check(
2000 Some(&identity),
2001 "owner/closed",
2002 download_request(vec![object(&oid, 10)])
2003 )
2004 .await,
2005 Err(LfsAdapterError::Forbidden(_))
2006 ));
2007 assert!(matches!(
2008 adapter
2009 .upload(Some(&identity), "owner/closed", &oid, Some(10), &b"x"[..])
2010 .await,
2011 Err(LfsAdapterError::Forbidden(_))
2012 ));
2013 assert!(matches!(
2014 adapter
2015 .download(Some(&identity), "owner/closed", &oid, None)
2016 .await,
2017 Err(LfsAdapterError::Forbidden(_))
2018 ));
2019 }
2020
2021 // the static claim does not admit either: the same
2022 // claim-carrying identity through the granting repo — the
2023 // live grant table (the wired provider's answer) decides.
2024 let claimed = identity_with_static_claim("u-nobody");
2025 assert!(
2026 matches!(
2027 adapter
2028 .check(
2029 Some(&claimed),
2030 "owner/granted",
2031 download_request(vec![object(&oid, 10)])
2032 )
2033 .await,
2034 Err(LfsAdapterError::Forbidden(_))
2035 ),
2036 "a resources claim rides nothing when the provider refuses"
2037 );
2038
2039 // the read-only grantee: read granted, write refused — the
2040 // batch action mapping (lfs.md §1: read/write ↔
2041 // download/upload) enforced through the wired provider.
2042 let reader = identity("u-reader");
2043 upload_media(&adapter, "owner/readonly", &content)
2044 .await
2045 .unwrap();
2046 let answer = adapter
2047 .check(
2048 Some(&reader),
2049 "owner/readonly",
2050 download_request(vec![object(&oid, content.len() as u64)]),
2051 )
2052 .await
2053 .unwrap();
2054 assert!(answer.objects[0].error.is_none());
2055 assert!(answer.objects[0]
2056 .actions
2057 .as_ref()
2058 .unwrap()
2059 .download
2060 .is_some());
2061 assert!(matches!(
2062 adapter
2063 .check(
2064 Some(&reader),
2065 "owner/readonly",
2066 upload_request(vec![object(&oid, 1)])
2067 )
2068 .await,
2069 Err(LfsAdapterError::Forbidden(_))
2070 ));
2071 let answer = adapter
2072 .download(Some(&reader), "owner/readonly", &oid, None)
2073 .await
2074 .unwrap();
2075 assert_eq!(read_all(answer).await, content);
2076
2077 // the provider's own rows: the public read (anon rides the
2078 // visibility, through the batch's provider), the denied
2079 // public write (push is always authenticated); the manage ⊃
2080 // write ⊃ read monotonicity, the unrecognized identity and
2081 // the unknown repo on a fresh provider.
2082 let anon = identity("anon");
2083 assert!(provider.owns(&anon, "blob-namespace", "owner/public", "read"));
2084 assert!(!provider.owns(&anon, "blob-namespace", "owner/public", "write"));
2085 let manages = record(
2086 "repo/m",
2087 Visibility::Private,
2088 &[("u-m", &[AccessAction::Manage])],
2089 );
2090 assert!(!manages.grants.is_empty());
2091 let provider2 = Arc::new(CachedRegistryProvider::new());
2092 provider2.install(vec![RegistryRow {
2093 record: manages,
2094 seq: 1,
2095 }]);
2096 let p: Arc<dyn OwnershipProvider> = Arc::new(LfsGrantProvider::new(provider2));
2097 // the ops resource actions are read/write — a stored manage
2098 // grant satisfies both (the authorize monotonicity); unknown
2099 // action strings deny; the unrecognized identity and the
2100 // unknown repo deny.
2101 assert!(p.owns(&identity("u-m"), "blob-namespace", "repo/m", "read"));
2102 assert!(p.owns(&identity("u-m"), "blob-namespace", "repo/m", "write"));
2103 assert!(!p.owns(&identity("u-x"), "blob-namespace", "repo/m", "read"));
2104 assert!(!p.owns(&identity("u-x"), "blob-namespace", "repo/m", "write"));
2105 assert!(!p.owns(&identity("u-m"), "blob-namespace", "repo/m", "manage"));
2106 assert!(!p.owns(&identity("u-m"), "blob-namespace", "repo/unknown", "read"));
2107 assert!(!p.owns(&identity("u-m"), "other-type", "repo/m", "read"));
2108 assert_eq!(
2109 p.owned_resources(&identity("u-m"), "blob-namespace"),
2110 vec!["repo/m".to_owned()]
2111 );
2112 }
2113
2114 /// The row assertion at check time, composed end-to-end: both
2115 /// arms assert, the malformed oid asserts nothing, the
2116 /// cross-reference composition bumps the rows' refcounts (the
2117 /// same oid referenced by two repos — the re-protection contract's
2118 /// data riding the adapter's assertions), and the re-assert is
2119 /// one row (the refcount-aware upsert).
2120 #[tokio::test]
2121 async fn the_rows_are_asserted_at_check_time() {
2122 let (adapter, _store, _rows) = adapter(grant_provider(), LfsAdapterConfig::default());
2123 let a = oid_of(b"shared media a");
2124 let malformed = "not-an-oid";
2125
2126 // upload arm, absent objects: asserted.
2127 adapter
2128 .check(
2129 Some(&identity("u-writer")),
2130 "owner/granted",
2131 upload_request(vec![object(&a, 15)]),
2132 )
2133 .await
2134 .unwrap();
2135 // download arm, absent objects: asserted too (the same posture).
2136 adapter
2137 .check(
2138 Some(&identity("u-writer")),
2139 "owner/readonly",
2140 download_request(vec![object(&a, 15), object(malformed, 15)]),
2141 )
2142 .await
2143 .unwrap();
2144
2145 let all = _rows.rows().await.unwrap();
2146 assert_eq!(all.len(), 2, "the malformed oid asserted nothing");
2147 for row in &all {
2148 assert_eq!(row.refcount, 2, "two repos — one key, two rows: {row:?}");
2149 }
2150
2151 // the re-assert refreshes the size; the refcount unmoved.
2152 adapter
2153 .check(
2154 Some(&identity("u-writer")),
2155 "owner/granted",
2156 upload_request(vec![object(&a, 15)]),
2157 )
2158 .await
2159 .unwrap();
2160 let all = _rows.rows().await.unwrap();
2161 let mine = all.iter().filter(|r| r.repo_id == "owner/granted").count();
2162 assert_eq!(mine, 1);
2163 }
2164}
2165
2166/// The pg fleet battery (F-S5's test half, the wave-10 booking): the
2167/// live-pg fleet assembly row — a kv-postgres **fleet** store under
2168/// the harness container, the adapter over it riding the live pg rows
2169/// twin — proving the fleet custody arm's confirm-immediately posture
2170/// consumes no admission slot across repeated uploads. The other
2171/// battery adapters are all `mem_only()` (the fleet owner resolves
2172/// `None`; only the custodial arm ever ran) — this is the fleet arm's
2173/// coverage: the fleet owner resolves (the arm's precondition), every
2174/// upload of fresh media succeeds (no `MintRefused`), the token
2175/// registry ends **empty** after every upload (each mint retired at
2176/// its immediate confirm — a dropped-unconfirmed token would hold its
2177/// admission slot to process end, the wave-9 gate's leak), the mint
2178/// boundary stays live per upload (an explicitly-held token persists
2179/// across an upload whose own mint lands and retires around it —
2180/// ADR-022 §5's hostile-loop posture, zero slot leak), and the pair
2181/// rows assert identically to the custodial arm's. Live-pg only;
2182/// clean skip when the harness env is absent (the `live_pg!`
2183/// convention).
2184#[cfg(all(test, feature = "postgres"))]
2185mod pg_fleet_tests {
2186 use std::collections::{BTreeMap, HashMap};
2187 use std::io::Cursor;
2188 use std::time::Duration;
2189
2190 use alkgit::{AccessAction, RepoRecord, Visibility};
2191
2192 use super::*;
2193 use crate::postgres::live_harness::{drop_schema, live_pg, unique_schema};
2194 use crate::registry::{CachedRegistryProvider, RegistryRow};
2195
2196 const REPO: &str = "owner/fleet-granted";
2197
2198 /// One fresh media body — the seed's little-endian form repeated,
2199 /// so every seed carries its own distinct header (each upload a
2200 /// new oid; the CAS re-put idempotence never degenerates the
2201 /// mints).
2202 fn media_of(seed: u64, len: usize) -> Vec<u8> {
2203 let pattern = seed.to_le_bytes();
2204 let mut out = Vec::with_capacity(len);
2205 while out.len() < len {
2206 out.extend_from_slice(&pattern);
2207 }
2208 out.truncate(len);
2209 out
2210 }
2211
2212 fn oid_of(content: &[u8]) -> String {
2213 hex_of(Sha256::digest(content).as_slice())
2214 }
2215
2216 fn identity() -> Identity {
2217 Identity {
2218 id: "u-writer".to_owned(),
2219 scopes: Vec::new(),
2220 resources: HashMap::new(),
2221 }
2222 }
2223
2224 fn grant_provider() -> Arc<dyn OwnershipProvider> {
2225 let provider = Arc::new(CachedRegistryProvider::new());
2226 provider.install(vec![RegistryRow {
2227 record: RepoRecord {
2228 repo_id: REPO.to_owned(),
2229 visibility: Visibility::Private,
2230 grants: BTreeMap::from([(
2231 "u-writer".to_owned(),
2232 vec![AccessAction::Read, AccessAction::Write],
2233 )]),
2234 },
2235 seq: 1,
2236 }]);
2237 Arc::new(LfsGrantProvider::new(provider))
2238 }
2239
2240 #[tokio::test(flavor = "multi_thread")]
2241 async fn the_fleet_handover_retires_every_admission_slot_across_repeated_uploads() {
2242 let dsn = live_pg!();
2243 let schema = unique_schema("lfs-fleet");
2244 let blobs_schema = format!("{schema}_blobs");
2245
2246 let rows = Arc::new(crate::lfs::PgLfsRows::new(
2247 crate::postgres::CoTenantPg::open(&dsn, &schema)
2248 .await
2249 .unwrap(),
2250 ));
2251 let fleet_store = Arc::new(
2252 alkblobs::Store::kv_only_postgres_fleet(
2253 &dsn,
2254 alkblobs::PgOpts {
2255 schema: blobs_schema.clone(),
2256 ..alkblobs::PgOpts::default()
2257 },
2258 1024 * 1024,
2259 Duration::ZERO,
2260 Duration::from_secs(60),
2261 )
2262 .await
2263 .unwrap(),
2264 );
2265 let adapter = LfsAdapter::new(
2266 Arc::clone(&fleet_store),
2267 rows.clone(),
2268 grant_provider(),
2269 LfsAdapterConfig {
2270 window_bytes: 128 * 1024,
2271 ..LfsAdapterConfig::default()
2272 },
2273 )
2274 .unwrap();
2275 let identity = identity();
2276
2277 // The fleet arm's precondition: the store's fleet declaration
2278 // resolves the serving instance's id (the mem battery never
2279 // reached this arm — every mint below is a fleet mint through
2280 // `hand_over`'s `Some(fleet)` branch).
2281 let owner = adapter
2282 .ops
2283 .fleet_owner()
2284 .expect("the fleet store carries the serving instance's id");
2285
2286 // Repeated uploads of fresh media: each rides the row
2287 // assertion, the pre-keyed put, then the fleet hand-over —
2288 // `mint_fleet` and the immediate confirm. None refuses at the
2289 // admission cap, and the registry ends empty after every one.
2290 const UPLOADS: usize = 12;
2291 for i in 0..UPLOADS as u64 {
2292 let body = media_of(i, 16 * 1024);
2293 let oid = oid_of(&body);
2294 adapter
2295 .upload(
2296 Some(&identity),
2297 REPO,
2298 &oid,
2299 Some(body.len() as u64),
2300 Cursor::new(&body),
2301 )
2302 .await
2303 .expect("the fleet upload lands");
2304 let pairs = rows.repo_rows(REPO).await.unwrap();
2305 assert!(
2306 pairs.iter().any(|row| row.oid == oid),
2307 "upload {i}'s pair asserted its row: {pairs:?}"
2308 );
2309 assert_eq!(
2310 adapter.ops.tokens().len(),
2311 0,
2312 "upload {i}'s mint retired at the confirm — no leaked slot"
2313 );
2314 }
2315 assert_eq!(
2316 rows.repo_rows(REPO).await.unwrap().len(),
2317 UPLOADS,
2318 "one row per upload pair"
2319 );
2320
2321 // The mint boundary's liveness: an explicitly-held token (a
2322 // fleet mint without the confirm — the custody the pin row's
2323 // TTL durably protects) counts its slot, and the next upload's
2324 // own mint lands and retires around it — the admission cap
2325 // stays live per upload with zero leak (the post-upload
2326 // registry carries exactly the held slot, nothing of the
2327 // upload's).
2328 let hold_body = media_of(0xff, 16 * 1024);
2329 let hold_key = lfs_media_key(&oid_of(&hold_body)).expect("the canonical key");
2330 let held = adapter
2331 .ops
2332 .mint_fleet(REPO, hold_key, &owner)
2333 .expect("the boundary mint");
2334 assert_eq!(adapter.ops.tokens().len(), 1, "the held token counts");
2335 let next = media_of(0xfffe, 16 * 1024);
2336 adapter
2337 .upload(
2338 Some(&identity),
2339 REPO,
2340 &oid_of(&next),
2341 Some(next.len() as u64),
2342 Cursor::new(&next),
2343 )
2344 .await
2345 .expect("the upload around the held token lands");
2346 assert_eq!(
2347 adapter.ops.tokens().len(),
2348 1,
2349 "the upload's mint retired around the held one — no leak"
2350 );
2351 assert!(
2352 adapter.ops.tokens().holds(&held),
2353 "the held token's slot persists (its durability is the pin row's TTL)"
2354 );
2355
2356 drop_schema(&dsn, &schema).await;
2357 drop_schema(&dsn, &blobs_schema).await;
2358 }
2359}