alkgit 0.1.0

Git smart protocol for the alk family: pkt-line substrates, protocol V2 serving (upload-pack/receive-pack), producer/consumer halves on alkcall channels, and the backend seam traits — the shipped alkstore+alkblobs composition is the alkgit-substrate crate (ADR-010/025)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
//! The consumer half — [`GitSession`] (ADR-017 §2 as amended by
//! ADR-023 §2, in the POC-X-validated shape): a typed git client over
//! the alkcall transport, wrapping **a construction**, not a new
//! transport trait impl. This module carries the session's
//! construction + handshake workstream and the shared drain helpers
//! both client workstreams build on; the fetch client
//! (`ls_refs`/`fetch`) and the push client (`push`) are the flat
//! sibling modules `session_fetch` and `session_push` (one module per
//! file, convention 14 — their types re-export here, so the crate's
//! public paths are unchanged).
//!
//! gix-transport's git-daemon client
//! ([`client::git::async_io::Connection`]) is generic over `(R, W)` —
//! any futures-io read/write pair — and the alkcall
//! [`BiStream`](alkcall::core::BiStream) split halves, compat-wrapped,
//! are exactly that pair ([`GitTransport`], the POC-X transport shape
//! productionized). Its [`ConnectMode`] carries the ADR-016 preamble
//! dialect verbatim:
//!
//! - [`ConnectMode::Daemon`] →
//!   [`GitSession::connect_direct`] — the transport's handshake writes
//!   ADR-016 §2's git-daemon request line onto the stream
//!   (`git-upload-pack <repo>\0host=<host>\0\0version=2\0` for fetch,
//!   `git-receive-pack <repo>\0host=<host>\0` for push) and reads the
//!   server's reply; the two native paths speak one preamble dialect —
//!   the same shapes the server's
//!   [`parse_request_line`](crate::parse_request_line) accepts.
//! - [`ConnectMode::Process`] →
//!   [`GitSession::open_via_channels`] — the transport's handshake
//!   writes nothing (the preamble rode the `channels/git/sub` open-op's
//!   `{repo, service}` params the caller dialed, ADR-016 §1) and reads
//!   the server's first bytes (the advertisement) as the handshake
//!   response; the session takes the post-open `BiStream`.
//!
//! One connection = one service machine (ADR-023 §2, ADR-016): a
//! session is constructed per service and per stream; the V2 session
//! persists across `ls-refs` → `fetch` on its stream (the fetch/push
//! machines land on this construction), push rides its own stream.
//!
//! The session is per-service and per-stream (ADR-016's
//! preamble-per-stream shape): the alkcall stream the session rides
//! stays the one connection — the V2 fetch session persists across
//! `ls-refs` → `fetch` (the machines' persistence contract, ADR-023
//! §2), push rides its own stream.
//!
//! **The operations** (their shapes live in the sibling modules): the
//! fetch client's V2 `ls-refs` command and the full client-side ack
//! loop ride gix-protocol's client machinery below the
//! `Negotiate`/RefMap layer (ADR-023 §6's recorded scope); the push
//! client is the hand-rolled send-pack (ADR-017 §3 — gitoxide has no
//! send-pack; a mirror of ADR-013's server shapes at the same honesty
//! bar, POC-X finding 3). The client speaks the grammar the captures
//! record and we serve (negotiation-captures.md, ADR-014's basis;
//! push-captures.md, ADR-013's basis).
//!
//! **No credentials** (ADR-017 §5): the construction passes no
//! handshake extra-parameters, leaves the transport's `custom_url`
//! (the authentication-helper context) unset, and imports nothing from
//! the git credentials machinery — the alkcall paths authenticate at
//! the transport, never at git's credentials-protocol layer. Identity
//! was established before this layer (vault → assembly → capabilities
//! — the no-secret-material invariant); alkvault holds replication
//! credentials at the app layer.
//!
//! **Bounded** (ADR-017 §6, ADR-009, same budget kinds client-side — no
//! new fields): the wall clock
//! ([`Limits::session_wall_clock`](crate::Limits::session_wall_clock))
//! bounds the session from construction and every operation built on
//! it; the handshake's advertisement reads are bounded by
//! [`Limits::max_advertisement_refs`](crate::Limits::max_advertisement_refs)
//! (lines) and
//! [`Limits::max_request_body`](crate::Limits::max_request_body)
//! (accumulated framed bytes — the client-direction twin of the duplex
//! readers' per-request check, review 004 S-1's class); the fetch
//! machines add
//! [`Limits::max_negotiation_rounds`](crate::Limits::max_negotiation_rounds)
//! (the round plan's cap), and both machines enforce
//! [`Limits::max_pack_size`](crate::Limits::max_pack_size) on the
//! response (the fetch pack sink / the push report read — the
//! construction task's recorded mapping).
//! Breaches end the session with a typed [`SessionError`], fail-closed:
//! the session enters a terminal state and re-reports its cached error
//! without re-entering the transport (the ADR-005 §4 error-break
//! discipline, client-side).

use std::ops::ControlFlow;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use alkcall::core::BiStream;
use bstr::BString;
use tokio::io::{ReadHalf, WriteHalf};
use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};

use gix_transport::client::async_io::SetServiceResponse;
use gix_transport::client::git::ConnectMode;
use gix_transport::client::{Capabilities, Error as ClientError};
use gix_transport::{Protocol, Service as GixService};

use crate::limits::Limits;
use crate::registry::validate_repo_id;
use crate::substrate::Service;

pub use crate::session_fetch::{FetchRound, FetchRoundReport};
pub use crate::session_push::{PushAdvertisedRef, PushReport, PushSendOptions};

/// The session's gix-transport construction (ADR-023 §2): gix-transport's
/// git-daemon client `Connection` over the alkcall [`BiStream`] split
/// halves, compat-wrapped — the POC-X-validated transport shape
/// re-landed in-crate.
///
/// Contract stamp: ADR-017 §2 as amended by ADR-023 §2 (the
/// construction wrapper — no new `Transport` trait impl); ADR-005 §1
/// (the split/compat bridge shape, productionized); ADR-016 §2/§4 (the
/// preamble dialect the mode mapping serves); core/w4-session-construction.
pub type GitTransport = gix_transport::client::git::async_io::Connection<
    Compat<ReadHalf<BiStream>>,
    Compat<WriteHalf<BiStream>>,
>;

/// Extra handshake parameters: none. The ADR-016 grammar is pinned and
/// closed (convention 9), and ADR-017 §5 wires no credentials layer —
/// `extra_parameters` is the handshake's extension surface and it
/// stays empty.
const HANDSHAKE_EXTRA_PARAMETERS: &[(&str, Option<&str>)] = &[];

/// The client agent token — `alkgit/<crate version>` (the served
/// advertisement's agent shape, ADR-013 §2, sent client-direction by
/// both session workstreams: the V2 commands' capability lines and the
/// push capabilities line's default `agent=` value).
pub(crate) const CLIENT_AGENT: &str =
    concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));

/// The direct-mode request line's desired-version encoding: gix's
/// `message::connect` appends the `version=<n>` token for everything
/// but `Protocol::V1`, so **fetch asks for `V2`** (the token lands —
/// ADR-003) and **push asks for `V1`** (no token — ADR-013 §1's
/// V0-framed grammar per ADR-016 §2). The version dimension lives in
/// the server's dispatch on the service alone; this is the request
/// line's shape selector, never a protocol negotiation.
fn desired_version(service: Service) -> Protocol {
    match service {
        Service::UploadPack => Protocol::V2,
        Service::ReceivePack => Protocol::V1,
    }
}

fn gix_service(service: Service) -> GixService {
    match service {
        Service::UploadPack => GixService::UploadPack,
        Service::ReceivePack => GixService::ReceivePack,
    }
}

/// Maps a gix client error into the session's typed vocabulary.
///
/// The io variant carries the reader's failures: an `ERR` line (the
/// peer's refusal — gix's fail-on-ERR posture wraps it into the io
/// error) downcasts to the packetline read error and rides across
/// verbatim (the ADR-008 collapsed denial's text arrives intact);
/// everything else is the transport below the protocol.
pub(crate) fn map_client_error(error: ClientError) -> SessionError {
    match error {
        ClientError::Io(e) => {
            let err_line = e
                .get_ref()
                .and_then(|inner| inner.downcast_ref::<gix_packetline::read::Error>());
            match err_line {
                Some(line) => SessionError::PeerRefused(line.message.to_string()),
                None => SessionError::Transport(e.to_string()),
            }
        }
        ClientError::LineDecode { .. } => SessionError::Protocol(format!(
            "malformed pkt-line in the handshake reply: {error}"
        )),
        ClientError::MissingHandshake
        | ClientError::Capabilities { .. }
        | ClientError::ExpectedLine(_)
        | ClientError::ExpectedDataLine
        | ClientError::UnsupportedProtocolVersion(_)
        | ClientError::AuthenticationUnsupported
        | ClientError::AuthenticationRefused(_) => {
            SessionError::Protocol(format!("undecodable handshake reply: {error}"))
        }
        other => SessionError::Transport(other.to_string()),
    }
}

/// The typed session error (ADR-017 §6's client-side failure
/// vocabulary: every construction/handshake failure class one variant,
/// no catch-all).
///
/// - [`SessionError::PeerRefused`] — the peer declined at the preamble
///   (a wire `ERR`: the ADR-008 denial, the V0/V1 fetch decline, the
///   git-upload-archive refusal); the `open_via_channels` dial's
///   refusals surface on alkcall's own typed error before the session
///   could exist (`ChannelOpenError`'s ADR-049 §3 vocabulary);
/// - [`SessionError::Protocol`] — the peer's handshake reply was
///   misframed or undecodable (fail-closed: never re-synced);
/// - [`SessionError::VersionRefused`] — the negotiated protocol
///   contradicts the service's pinned version (fetch must negotiate
///   V2 — ADR-003; push is V0-framed unconditionally — ADR-013 §1);
/// - [`SessionError::WallClock`] — the session wall clock expired
///   (ADR-009's session budget, client direction);
/// - [`SessionError::BudgetExceeded`] — a client-side budget breach on
///   the same budget kinds the server side enforces (no new fields —
///   ADR-009's rule); the response-pack budget
///   ([`Limits::max_pack_size`](crate::Limits::max_pack_size)) is
///   enforced by the fetch/push machines;
/// - [`SessionError::Transport`] — the transport failed below the
///   protocol (message-only per the
///   [`RegistryError::Io`](crate::RegistryError::Io) stability
///   rule — never a raw `std::io::Error`);
/// - [`SessionError::InvalidInput`] — the caller's inputs failed the
///   session's local grammar (a repo id off the repo-id grammar, a
///   host carrying framing bytes); nothing crossed the wire.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SessionError {
    /// The peer declined the session at the preamble — a wire `ERR`
    /// line surfaced by the handshake reader's fail-on-ERR posture.
    #[error("the peer refused the git session: {0}")]
    PeerRefused(String),
    /// The peer's handshake reply was misframed or undecodable — the
    /// session ends (fail-closed; never a re-sync).
    #[error("protocol error in the git session handshake: {0}")]
    Protocol(String),
    /// The negotiated protocol contradicts the service's pinned
    /// version (ADR-003: fetch is V2-only; ADR-013 §1: push is
    /// V0-framed unconditionally). Fail-closed: the session ends.
    #[error(
        "the peer negotiated protocol {negotiated:?} for {service} — the service's pinned \
         version contradicts it; fail closed (ADR-003/ADR-013)"
    )]
    VersionRefused {
        /// The service the session was established for.
        service: Service,
        /// The protocol the peer's reply negotiated.
        negotiated: Protocol,
    },
    /// The session wall clock expired (ADR-009's session budget,
    /// client-side — fail-closed).
    #[error(
        "the git session exceeded the session_wall_clock budget ({budget:?}) — fail closed (ADR-009)"
    )]
    WallClock {
        /// The wall-clock budget the session was given.
        budget: Duration,
    },
    /// A client-side budget breach on the same budget kinds the server
    /// side enforces (ADR-009's fail-closed discipline; no new fields).
    #[error(
        "client-side budget breach — the peer's {what} exceeded the {budget_name} budget \
         ({limit}) — fail closed (ADR-009, client-side)"
    )]
    BudgetExceeded {
        /// What bounded input overflowed (e.g. `the advertisement`).
        what: String,
        /// The budget kind's field name (e.g. `max_request_body`,
        /// `max_advertisement_refs`).
        budget_name: &'static str,
        /// The limit that was breached.
        limit: u64,
    },
    /// The transport failed below the protocol — terminal. Message-only
    /// by pin (the
    /// [`RegistryError::Io`](crate::RegistryError::Io) stability
    /// rule: a public type's transport-error surface never carries
    /// `std::io::Error`).
    #[error("git session transport failure: {0}")]
    Transport(String),
    /// The caller's inputs failed the session's local grammar (a repo
    /// id off the repo-id grammar, a host carrying framing bytes) — the
    /// `invalid` class (backend.md §repo-id grammar: the caller needs
    /// the correction). Nothing crossed the wire.
    #[error("invalid git session input: {0}")]
    InvalidInput(String),
}

/// The handshake's outcome (ADR-017 §2): the protocol the peer
/// negotiated, the capabilities it advertised, and — for the
/// V0-framed push service — the remaining advertisement lines drained
/// to owned payloads (gix hands the V0 reply's refs as a borrowed
/// reader; the borrow cannot be stored on the session, so the drain is
/// the handshake's tail, bounded like every read).
#[derive(Debug, Clone)]
pub struct HandshakeResponse {
    /// The protocol the peer negotiated — the construction's
    /// version-detection verbatim, including gix's normalization: a
    /// V0-framed advertisement (ADR-013 §1's push reply, caps riding
    /// the first line's NUL suffix) parses as `V1`.
    pub protocol: Protocol,
    /// The capabilities the peer advertised (the V2 capability list,
    /// or a V0/V1 reply's first-line caps).
    pub capabilities: Capabilities,
    /// The advertisement's remaining ref lines, drained to owned raw
    /// payloads (empty for a V2 handshake — the V2 advertisement has
    /// no refs). Each element is one pkt-line payload's wire bytes; on
    /// a V0 reply the first line's NUL-attached caps segment was moved
    /// to [`HandshakeResponse::capabilities`] by gix's reader
    /// normalization (`<oid> <ref>` rides here). The push machine
    /// (core/w4-session-push) parses this list.
    pub refs: Vec<BString>,
}

/// The typed git session — the consumer half (ADR-017 §1: the
/// replicator's primitive).
///
/// Construction picks the preamble dialect (the two constructors);
/// the session's first exchange is [`GitSession::handshake`] — the
/// preamble rides out and the server's reply is consumed as the
/// session's state ([`HandshakeResponse`]) under the wall clock. The
/// session then carries the transport for its operations
/// (`ls_refs`/`fetch`/`push` — `core/w4-session-fetch` /
/// `core/w4-session-push`), which land on this construction.
///
/// # Runtime bound (ADR-017 §2)
///
/// gix-transport's async client traits are `#[async_trait(?Send)]` —
/// a session drives **one operation at a time on one task**; the
/// session's async state machines are not `Send`. Drive the session on
/// the task that constructed it, one operation after another — the
/// design shape that matches how a replicator uses a remote.
///
/// # Lifecycle
///
/// The session ends when dropped: the alkcall stream closes and the
/// peer's serving session observes the clean-EOF close (ADR-005 §1's
/// quiet session end; the operations' wire shapes decide any explicit
/// close discipline). After any error the session is terminal: later
/// calls re-report the cached error without re-entering the transport.
///
/// # No credentials (ADR-017 §5)
///
/// The session carries no credential surface — the handshake's
/// extension parameters stay empty and the git-credentials machinery
/// appears nowhere; see the module doc.
pub struct GitSession {
    transport: GitTransport,
    pub(crate) service: Service,
    repo: String,
    pub(crate) limits: Limits,
    deadline: Instant,
    handshake: Option<HandshakeResponse>,
    pub(crate) terminal: Option<SessionError>,
}

impl std::fmt::Debug for GitSession {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GitSession")
            .field("service", &self.service)
            .field("repo", &self.repo)
            .field("handshaken", &self.handshake.is_some())
            .field("terminal", &self.terminal.is_some())
            .field("remaining_wall_clock", &self.remaining_wall_clock())
            .finish_non_exhaustive()
    }
}

impl GitSession {
    /// Constructs the session over an already-established alkcall
    /// `alk/git` stream (the direct path's dialing responsibility is
    /// the caller's alkcall connection) — the preamble rides the
    /// transport's handshake in [`ConnectMode::Daemon`]:
    /// [`GitSession::handshake`] writes the ADR-016 §2 request line
    /// (`git-upload-pack <repo>\0host=<host>\0\0version=2\0` — the
    /// host optional, the shape the server's
    /// [`parse_request_line`](crate::parse_request_line) accepts) and
    /// reads the server's reply.
    ///
    /// The repo id is validated against the repo-id grammar before
    /// anything touches the wire (fail fast — the `invalid` class,
    /// [`SessionError::InvalidInput`]); the host may not carry the
    /// grammar's framing bytes (NUL/LF).
    ///
    /// Contract stamp: ADR-017 §2 (the direct constructor), ADR-016 §2
    /// (the request-line grammar), ADR-009 (wall clock from
    /// construction); core/w4-session-construction.
    pub fn connect_direct(
        stream: BiStream,
        repo: &str,
        service: Service,
        host: Option<&str>,
        limits: Limits,
    ) -> Result<Self, SessionError> {
        let started = Instant::now();
        if let Err(cause) = validate_repo_id(repo) {
            return Err(SessionError::InvalidInput(format!(
                "invalid repo id {repo:?}: {cause}"
            )));
        }
        if let Some(host) = host {
            if host.bytes().any(|byte| byte == 0 || byte == b'\n') {
                return Err(SessionError::InvalidInput(format!(
                    "the host carries the request line's framing bytes (NUL/LF), ADR-016 §2: {host:?}"
                )));
            }
        }
        let (read_half, write_half) = tokio::io::split(stream);
        let transport = gix_transport::client::git::async_io::Connection::new(
            read_half.compat(),
            write_half.compat_write(),
            desired_version(service),
            repo.to_owned(),
            host.map(|host| (host.to_owned(), None::<u16>)),
            ConnectMode::Daemon,
            false,
        );
        Ok(Self::assemble(transport, repo, service, limits, started))
    }

    /// Constructs the session over the alkcall channels path — the
    /// **post-open** `BiStream`: the preamble rode the open-op's
    /// `{repo, service}` params (ADR-016 §1), so the transport's
    /// handshake (in [`ConnectMode::Process`]) **writes nothing** and
    /// reads the server's first bytes (the advertisement) as its
    /// response; the server speaks at open time.
    ///
    /// The dialing responsibility is the contract this constructor
    /// documents (the shape-call decision, flagged for the gate): the
    /// caller already dialed `channels/git/sub` through alkcall's client
    /// path — `open_channel(OP_GIT_OPEN, json!({"repo": …, "service":
    /// …}), GIT_ALPN)` — and hands the opened stream here; the params
    /// schema is pinned by the peer's open gate (the establisher
    /// fail-closes a wrong shape at open time, ADR-016 §1). The repo id
    /// is still validated against the repo-id grammar here (the same
    /// local input check as the direct constructor).
    ///
    /// Contract stamp: ADR-017 §2 (the channels constructor), ADR-016
    /// §1/§4 (the open-op params the caller sent + the mirrored
    /// constructor shapes), ADR-009 (wall clock from construction);
    /// core/w4-session-construction.
    pub fn open_via_channels(
        stream: BiStream,
        repo: &str,
        service: Service,
        limits: Limits,
    ) -> Result<Self, SessionError> {
        if let Err(cause) = validate_repo_id(repo) {
            return Err(SessionError::InvalidInput(format!(
                "invalid repo id {repo:?}: {cause}"
            )));
        }
        let (read_half, write_half) = tokio::io::split(stream);
        let transport = gix_transport::client::git::async_io::Connection::new(
            read_half.compat(),
            write_half.compat_write(),
            desired_version(service),
            repo.to_owned(),
            None::<(String, Option<u16>)>,
            ConnectMode::Process,
            false,
        );
        Ok(Self::assemble(
            transport,
            repo,
            service,
            limits,
            Instant::now(),
        ))
    }

    fn assemble(
        transport: GitTransport,
        repo: &str,
        service: Service,
        limits: Limits,
        started: Instant,
    ) -> Self {
        GitSession {
            transport,
            service,
            repo: repo.to_owned(),
            deadline: started + limits.session_wall_clock,
            limits,
            handshake: None,
            terminal: None,
        }
    }

    /// The service this session was established for (ADR-016 §3 — the
    /// state-machine selector; one connection = one service machine).
    pub fn service(&self) -> Service {
        self.service
    }

    /// The repo id this session resolved on the peer (the request's own
    /// input — carried for the caller's bookkeeping).
    pub fn repo(&self) -> &str {
        &self.repo
    }

    /// The session's budget bundle (ADR-009, client-side; enforcement
    /// as the operations build on it).
    pub fn limits(&self) -> &Limits {
        &self.limits
    }

    /// The wall clock left to this session (saturating — past the
    /// deadline the operations build on it fail-closed immediately).
    pub fn remaining_wall_clock(&self) -> Duration {
        self.deadline.saturating_duration_since(Instant::now())
    }

    /// The session's transport — the wrapped gix-transport construction
    /// the operations drive (in-crate: the operations are the session's
    /// surface; no pkt-line exposure past this module, ADR-017).
    pub(crate) fn transport_mut(&mut self) -> &mut GitTransport {
        &mut self.transport
    }

    /// The session's first exchange (ADR-017 §2): the preamble rides
    /// out (direct mode writes the ADR-016 §2 request line; channels
    /// mode writes nothing — the preamble rode the open op) and the
    /// server's response is consumed as the handshake state
    /// ([`HandshakeResponse`]).
    ///
    /// The whole exchange is bounded by the session's remaining wall
    /// clock (ADR-009 — the wall clock bounds every operation, the
    /// handshake included); the negotiated protocol is checked against
    /// the service's pinned version (fetch demands V2 — ADR-003; push
    /// refuses a V2 reply — ADR-013 §1) and both fail closed. On a
    /// V0/V1-shaped reply (push) the advertisement's ref lines drain
    /// to owned payloads bounded by
    /// [`max_advertisement_refs`](crate::Limits::max_advertisement_refs)
    /// and [`max_request_body`](crate::Limits::max_request_body).
    ///
    /// A second call returns the cached response without wire re-entry
    /// (the handshake runs once per session — one connection, one
    /// service machine). After any error the session is terminal and
    /// re-reports the cached error.
    ///
    /// Contract stamp: ADR-017 §2/§5/§6 (the handshake, no credentials,
    /// bounded), ADR-016 §2 (the write side of the direct mode's
    /// handshake), ADR-023 §2 (the Process mode's zero-write
    /// handshake), ADR-009 (the wall clock + the advertisement
    /// bounds); core/w4-session-construction.
    pub async fn handshake(&mut self) -> Result<HandshakeResponse, SessionError> {
        if let Some(cached) = &self.handshake {
            return Ok(cached.clone());
        }
        if let Some(cached) = &self.terminal {
            return Err(cached.clone());
        }
        let bounds = AdvertisementBounds {
            max_refs: self.limits.max_advertisement_refs,
            max_bytes: self.limits.max_request_body,
        };
        match self.handshake_once(bounds).await {
            Ok(response) => {
                self.handshake = Some(response.clone());
                Ok(response)
            }
            Err(error) => {
                self.terminal = Some(error.clone());
                Err(error)
            }
        }
    }

    async fn handshake_once(
        &mut self,
        bounds: AdvertisementBounds,
    ) -> Result<HandshakeResponse, SessionError> {
        use gix_transport::client::async_io::Transport as _;

        let service = self.service;
        let remaining = self.remaining_wall_clock();
        let exchange = tokio::time::timeout(remaining, async {
            let response = self
                .transport_mut()
                .handshake(gix_service(service), HANDSHAKE_EXTRA_PARAMETERS)
                .await
                .map_err(map_client_error)?;
            let SetServiceResponse {
                actual_protocol,
                capabilities,
                refs,
            } = response;
            match service {
                Service::UploadPack => {
                    if actual_protocol != Protocol::V2 {
                        return Err(SessionError::VersionRefused {
                            service,
                            negotiated: actual_protocol,
                        });
                    }
                    Ok(HandshakeResponse {
                        protocol: actual_protocol,
                        capabilities,
                        refs: Vec::new(),
                    })
                }
                Service::ReceivePack => {
                    if actual_protocol == Protocol::V2 {
                        return Err(SessionError::VersionRefused {
                            service,
                            negotiated: actual_protocol,
                        });
                    }
                    let refs = match refs {
                        Some(mut reader) => drain_advertisement(&mut reader, bounds).await?,
                        None => Vec::new(),
                    };
                    Ok(HandshakeResponse {
                        protocol: actual_protocol,
                        capabilities,
                        refs,
                    })
                }
            }
        });
        match exchange.await {
            Ok(inner) => inner,
            Err(_elapsed) => Err(SessionError::WallClock {
                budget: self.limits.session_wall_clock,
            }),
        }
    }

    pub(crate) fn typed_wall_clock_breach(&mut self) -> SessionError {
        let error = SessionError::WallClock {
            budget: self.limits.session_wall_clock,
        };
        self.terminal = Some(error.clone());
        error
    }
}

/// The handshake's advertisement drain bounds (the same budget kinds
/// the server side enforces, client direction — no new fields).
pub(crate) struct AdvertisementBounds {
    /// The line count cap
    /// ([`max_advertisement_refs`](crate::Limits::max_advertisement_refs)).
    pub(crate) max_refs: usize,
    /// The accumulated framed-bytes cap
    /// ([`max_request_body`](crate::Limits::max_request_body)).
    pub(crate) max_bytes: u64,
}

/// The bounded line-drain loop both advertisement-shaped response
/// drains share (review 005 R-1's fold-in: one helper, the err-line
/// downcast block and the byte/ref budget-check shape verbatim): each
/// iteration reads one pkt-line payload line, breaches of
/// `max_request_body` (accumulated bytes) or `max_advertisement_refs`
/// (line count) are typed budget errors fail-closed, and each line is
/// handed to `on_line` whose typed result accumulates. A zero-length
/// read is the drain's end (the framing's flush or the clean close).
///
/// The reader (gix's `ReadlineBufRead` over the peek buffer) yields
/// one pkt-line payload per call; its drop re-arms the transport's
/// line reader at the framing's flush — the session sits positioned
/// after the response, ready for the next operation.
///
/// Contract stamp: ADR-009 (the response bounds — same kinds, no new
/// fields); review 005 R-1 (the dedup).
pub(crate) async fn drain_bounded_lines<R, T>(
    reader: &mut R,
    bounds: AdvertisementBounds,
    what: &'static str,
    mut on_line: impl FnMut(&str) -> Result<T, SessionError>,
) -> Result<Vec<T>, SessionError>
where
    R: gix_transport::client::async_io::ReadlineBufRead + Unpin,
{
    let mut items = Vec::new();
    let mut bytes = 0u64;
    let mut line = String::new();
    loop {
        line.clear();
        let n = match reader.readline_str(&mut line).await {
            Ok(n) => n,
            Err(e) => {
                let err_line = e
                    .get_ref()
                    .and_then(|inner| inner.downcast_ref::<gix_packetline::read::Error>());
                return Err(match err_line {
                    Some(line) => SessionError::PeerRefused(line.message.to_string()),
                    None => SessionError::Transport(e.to_string()),
                });
            }
        };
        if n == 0 {
            return Ok(items);
        }
        bytes += n as u64;
        if bytes > bounds.max_bytes {
            return Err(SessionError::BudgetExceeded {
                what: what.to_owned(),
                budget_name: "max_request_body",
                limit: bounds.max_bytes,
            });
        }
        if items.len() >= bounds.max_refs {
            return Err(SessionError::BudgetExceeded {
                what: what.to_owned(),
                budget_name: "max_advertisement_refs",
                limit: bounds.max_refs as u64,
            });
        }
        items.push(on_line(&line)?);
    }
}

/// Drains the V0/V1-shaped advertisement the handshake handed over
/// (line-per-payload) under the bounds: a line past
/// `max_advertisement_refs` or an accumulated byte total past
/// [`max_request_body`](crate::Limits::max_request_body) ends the
/// session with the typed budget error — fail-closed, the client-direction
/// twin of the duplex readers' per-request accumulated-bytes check.
/// Each drained line is kept as its raw wire payload — the push
/// machine (core/w4-session-push) parses the list.
///
/// Contract stamp: ADR-009 (the advertisement bounds — same kinds, no
/// new fields); core/w4-session-construction.
async fn drain_advertisement<R>(
    reader: &mut R,
    bounds: AdvertisementBounds,
) -> Result<Vec<BString>, SessionError>
where
    R: gix_transport::client::async_io::ReadlineBufRead + Unpin,
{
    drain_bounded_lines(reader, bounds, "the advertisement", |line| {
        Ok(BString::from(line.as_bytes()))
    })
    .await
}

/// The bounded sideband-drain pump both response consumes share
/// (review 005 R-1's fold-in: `pump_pack`'s done-round pack stream and
/// `read_push_report`'s status report read were the identical
/// fill-buf/consume/`max_pack_size`-budget loop over the response
/// reader's `ExtendedBufRead` handle). When `band_handler` rides, the
/// reader's progress handler unwraps the sideband — band 1's chunks
/// are the payload (streamed to `sink`), band 2's progress text is
/// skipped, band 3 is the peer's abort (recorded and the pump breaks —
/// the typed protocol error carries the peer's text). The pump ends at
/// the section's flush (the reader's stop); the sink is flushed before
/// the round-trip returns. `what` names the budget breach, `section`
/// names the wire section in the band-abort and stream-failure errors.
///
/// Contract stamp: ADR-009 (the response pack budget, client-side —
/// the sink never receives a byte past the budget); review 005 R-1
/// (the dedup).
pub(crate) async fn drain_banded_payload<S>(
    reader: &mut Box<dyn gix_transport::client::async_io::ExtendedBufRead<'_> + Unpin + '_>,
    sink: &mut S,
    band_handler: bool,
    max_pack_size: u64,
    what: &'static str,
    section: &'static str,
) -> Result<u64, SessionError>
where
    S: futures::io::AsyncWrite + Unpin,
{
    use futures::io::{AsyncBufReadExt as _, AsyncWriteExt as _};

    let band_error: Arc<Mutex<Option<String>>> = Arc::default();
    if band_handler {
        reader.set_progress_handler(Some(Box::new({
            let band_error = Arc::clone(&band_error);
            move |is_error: bool, text: &[u8]| {
                if is_error {
                    *band_error.lock().unwrap_or_else(|e| e.into_inner()) =
                        Some(String::from_utf8_lossy(text).into_owned());
                    ControlFlow::Break(())
                } else {
                    ControlFlow::Continue(())
                }
            }
        })));
    }
    let base: &mut (dyn futures::io::AsyncBufRead + Unpin) = reader.as_mut();
    let mut total = 0u64;
    loop {
        let delivered = base.fill_buf().await;
        match delivered {
            Ok(ready) => {
                if ready.is_empty() {
                    break;
                }
                let len = ready.len();
                if total + len as u64 > max_pack_size {
                    return Err(SessionError::BudgetExceeded {
                        what: what.to_owned(),
                        budget_name: "max_pack_size",
                        limit: max_pack_size,
                    });
                }
                sink.write_all(ready).await.map_err(|e| {
                    SessionError::Transport(format!("the pack consumer failed: {e}"))
                })?;
                total += len as u64;
                base.consume_unpin(len);
            }
            Err(_) => {
                return Err(
                    match band_error.lock().unwrap_or_else(|e| e.into_inner()).take() {
                        Some(text) => SessionError::Protocol(format!(
                            "{section} aborted by the peer's error band: {text}"
                        )),
                        None => SessionError::Transport(format!("{section}'s stream failed")),
                    },
                );
            }
        }
    }
    sink.flush()
        .await
        .map_err(|e| SessionError::Transport(format!("the pack consumer failed: {e}")))?;
    Ok(total)
}

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

    /// Contract stamp: ADR-017 §5 (no credentials — the handshake's
    /// only credential-adjacent surface, the extra-parameters, stays
    /// empty; the transport's authentication-helper context is never
    /// set; the module imports nothing from the git credentials
    /// machinery); core/w4-session-construction.
    #[test]
    fn the_session_wires_no_credentials_surface() {
        assert!(
            HANDSHAKE_EXTRA_PARAMETERS.is_empty(),
            "no hand-wired handshake parameters: the ADR-016 grammar is closed and no credentials layer rides it"
        );
    }

    /// Contract stamp: ADR-016 §2 (the fetch line's version=2 token),
    /// ADR-013 §1 (push has none — V0-framed); the encoding is gix's
    /// desired-version rule (the token is appended for V2, never for
    /// V1); core/w4-session-construction.
    #[test]
    fn the_request_line_version_encoding_is_the_pinned_grammar() {
        assert_eq!(desired_version(Service::UploadPack), Protocol::V2);
        assert_eq!(desired_version(Service::ReceivePack), Protocol::V1);
    }

    /// The typed mapping of gix's handshake errors: a wire `ERR` rides
    /// as [`SessionError::PeerRefused`] with the refusal text intact
    /// (the ADR-008 denial shape arrives verbatim); a plain io failure
    /// is [`SessionError::Transport`], message-only.
    #[test]
    fn handshake_errors_map_typed() {
        let refused = ClientError::Io(std::io::Error::other(gix_packetline::read::Error {
            message: "access denied: owner/doubled".into(),
        }));
        assert_eq!(
            map_client_error(refused),
            SessionError::PeerRefused("access denied: owner/doubled".to_owned()),
            "the peer's decline text survives the mapping"
        );
        assert!(matches!(
            map_client_error(ClientError::Io(std::io::Error::other("gone"))),
            SessionError::Transport(_)
        ));
        assert!(matches!(
            map_client_error(ClientError::ExpectedLine("text")),
            SessionError::Protocol(_)
        ));
    }
}