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
//! The native session preamble (ADR-016 §2): the git-daemon request
//! line — the direct-ALPN `alk/git` path's in-band service carrier.
//!
//! One pkt-line, client-sent before the server speaks (POC-1-verbatim,
//! capture-backed against real git 2.43 — poc-1-findings §2,
//! push-captures §git:// framing):
//!
//! - fetch: `git-upload-pack <repo>\0host=<host>\0\0version=2\0`
//! - push: `git-receive-pack <repo>\0host=<host>\0`
//!
//! The grammar is alkgit-specific wire format on a published ALPN — it
//! enters OQ-03's freeze inventory (one-way, convention 9): `host=`
//! and `version=2` extras are accepted verbatim, unknown extras are
//! accepted and ignored (the git-daemon's tolerance), and the failure
//! set is pinned — an unknown service (which includes
//! `git-upload-archive`: not in the ADR-016 enum, rejected at the
//! gate), a malformed line shape, and the V0/V1 fetch decline (a fetch
//! request line without `version=2` or with an unrecognized version
//! token — ADR-003's clear error, no advertisement). The repo id is an
//! opaque registry key — validated against the repo-id grammar for
//! rejection only, never decomposed or path-joined (ADR-008,
//! backend.md §repo-id grammar); on the serving paths a grammar-invalid
//! id collapses with unknown-repo into the generic denial
//! ([`RequestLineError::RepoInvalid`] carries the id for exactly that
//! shared shape), never a distinct invalid-id wire error.
//!
//! Every decline here is repo-blind — the messages name only what the
//! client itself sent — so no preamble failure leaks repository
//! existence ahead of the resolve→authorize gate.

use tokio::io::{AsyncRead as TokioAsyncRead, AsyncReadExt};

use crate::registry::{validate_repo_id, RepoIdError};
use crate::substrate::Service;

/// The request line's byte cap (convention 11 — bounded resources):
/// the grammar's largest legitimate shape (a maximal two-segment repo
/// id, a host, the version token) is far below this; the cap is the
/// rejection surface, never a silent truncation.
pub const MAX_REQUEST_LINE_BYTES: usize = 4096;

/// One parsed request line: the service the line selects and the
/// (grammar-validated) repo id it resolves. The `host=` and
/// `version=2` extras are accepted verbatim and not carried — the
/// service alone selects the state machine (ADR-016 §3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestLine {
    service: Service,
    repo: String,
}

impl RequestLine {
    /// The service the request line selected.
    pub fn service(&self) -> Service {
        self.service
    }

    /// The repo id — the opaque registry key resolved by the gate.
    pub fn repo(&self) -> &str {
        &self.repo
    }
}

#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RequestLineError {
    #[error("malformed request line: {0}")]
    Malformed(String),
    #[error("unknown service: {0}")]
    ServiceUnknown(String),
    #[error("invalid repo id {repo:?}: {cause}")]
    RepoInvalid { repo: String, cause: RepoIdError },
    #[error(
        "only protocol v2 is served for fetch — the request line must carry version=2 (ADR-003)"
    )]
    VersionDeclined { token: Option<String> },
}

impl RequestLineError {
    /// The decline band's message — the clear, repo-blind text for the
    /// pinned refusal set ([`RequestLineError::Malformed`],
    /// [`RequestLineError::ServiceUnknown`],
    /// [`RequestLineError::VersionDeclined`]). A
    /// [`RequestLineError::RepoInvalid`] has no decline message: on the
    /// serving paths its shape collapses with unknown-repo into the
    /// generic denial, not a distinct `invalid` wire error (backend.md
    /// §repo-id grammar, ADR-008).
    pub fn decline_message(&self) -> Option<String> {
        match self {
            RequestLineError::Malformed(why) => {
                Some(format!("malformed request line: {why}"))
            }
            RequestLineError::ServiceUnknown(service) => {
                Some(format!("unknown service: {service}"))
            }
            RequestLineError::RepoInvalid { .. } => None,
            RequestLineError::VersionDeclined { token: None } => Some(
                "only protocol v2 is served for fetch — the request line must carry version=2 (ADR-003)"
                    .to_owned(),
            ),
            RequestLineError::VersionDeclined { token: Some(token) } => Some(format!(
                "only protocol v2 is served for fetch — the request line must carry version=2 \
                 (unrecognized version token: {token})"
            )),
        }
    }
}

/// Parses one request-line payload (the bytes inside the pkt-line
/// frame, without the length prefix) into the session-establishment
/// inputs. Both ADR-016 service shapes parse; the version dispatch
/// rides the parse too — a fetch line without `version=2` or with an
/// unrecognized version token is the V0/V1-decline path (ADR-003),
/// before any advertisement.
pub fn parse_request_line(payload: &str) -> Result<RequestLine, RequestLineError> {
    let (body, extras) = payload.split_once('\0').unwrap_or((payload, ""));
    let (service_str, repo) = body
        .split_once(' ')
        .ok_or_else(|| RequestLineError::Malformed("expected \"<service> <repo>\"".to_owned()))?;
    let service = service_str
        .parse::<Service>()
        .map_err(|_| RequestLineError::ServiceUnknown(service_str.to_owned()))?;
    validate_repo_id(repo).map_err(|cause| RequestLineError::RepoInvalid {
        repo: repo.to_owned(),
        cause,
    })?;

    let mut version_token: Option<String> = None;
    for extra in extras.split('\0') {
        if extra.is_empty() {
            continue;
        }
        if extra.starts_with("host=") {
            continue;
        }
        if let Some(token) = extra.strip_prefix("version=") {
            version_token = Some(token.to_owned());
        }
    }
    match service {
        Service::ReceivePack => {}
        Service::UploadPack => match version_token.as_deref() {
            Some("2") => {}
            other => {
                return Err(RequestLineError::VersionDeclined {
                    token: other.map(str::to_owned),
                })
            }
        },
    }

    Ok(RequestLine {
        service,
        repo: repo.to_owned(),
    })
}

/// Reads the request line off the stream start — the direct-ALPN path's
/// first frame, consumed before any server byte (the connection is
/// symmetric; without this in-band carrier the path would deadlock,
/// ADR-016 §2).
///
/// The frame is read with exact, bounded reads (no read-ahead buffering
/// — the stream continues into the protocol session untouched); the
/// reading ends at the frame boundary and the caller hands the
/// stream (with the preamble consumed) to the session machinery.
///
/// - `Ok(Some(line))` — a request line arrived and parses.
/// - `Ok(None)` — the peer closed the stream before any byte; the
///   session is over quietly (no response is written).
/// - `Err(_)` — a malformed/truncated frame, an unknown service, a
///   grammar-invalid repo id, or the V0/V1 fetch decline.
pub async fn read_request_line<T>(stream: &mut T) -> Result<Option<RequestLine>, RequestLineError>
where
    T: TokioAsyncRead + Unpin,
{
    let mut header = [0u8; 4];
    let mut filled = 0;
    loop {
        match stream.read(&mut header[filled..]).await {
            Ok(0) => {
                return if filled == 0 {
                    Ok(None)
                } else {
                    Err(RequestLineError::Malformed(
                        "the pkt-line header was truncated".to_owned(),
                    ))
                };
            }
            Ok(n) => filled += n,
            Err(e) => {
                return Err(RequestLineError::Malformed(format!(
                    "the request-line read failed: {e}"
                )))
            }
        }
        if filled == header.len() {
            break;
        }
    }
    let total = usize::from_str_radix(
        std::str::from_utf8(&header)
            .map_err(|_| RequestLineError::Malformed("non-UTF-8 pkt-line header".to_owned()))?,
        16,
    )
    .map_err(|_| RequestLineError::Malformed("non-hex pkt-line header length".to_owned()))?;
    if total < 4 {
        return Err(RequestLineError::Malformed(format!(
            "invalid pkt-line length {total}"
        )));
    }
    let line_bytes = total - 4;
    if line_bytes > MAX_REQUEST_LINE_BYTES {
        return Err(RequestLineError::Malformed(format!(
            "the request line exceeds the {MAX_REQUEST_LINE_BYTES}-byte cap"
        )));
    }
    let mut payload = vec![0u8; line_bytes];
    stream
        .read_exact(&mut payload)
        .await
        .map_err(|_| RequestLineError::Malformed("the request line was truncated".to_owned()))?;
    let text = String::from_utf8(payload).map_err(|_| {
        RequestLineError::Malformed("the request line payload is not UTF-8".to_owned())
    })?;
    parse_request_line(&text).map(Some)
}

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

    /// Contract stamp: ADR-016 §2 (the fetch request-line shape,
    /// POC-1-verbatim); core/w2-native-transport.
    #[test]
    fn the_fetch_request_line_parses_with_host_and_version_extras() {
        let line =
            parse_request_line("git-upload-pack owner/doubled\0host=example.com\0\0version=2\0")
                .unwrap();
        assert_eq!(line.service(), Service::UploadPack);
        assert_eq!(line.repo(), "owner/doubled");
    }

    /// Contract stamp: ADR-016 §2 (the push request-line shape,
    /// push-captures git:// framing — no version token); ADR-013 §1
    /// (push is V0-framed unconditionally); core/w2-native-transport.
    #[test]
    fn the_push_request_line_parses_without_a_version_token() {
        let line =
            parse_request_line("git-receive-pack owner/doubled\0host=example.com\0").unwrap();
        assert_eq!(line.service(), Service::ReceivePack);
        assert_eq!(line.repo(), "owner/doubled");
    }

    /// The line parses with no extras at all (a push line ends at the
    /// repo id; the fetch shape still demands its version token).
    #[test]
    fn a_service_repo_line_without_extras_parses() {
        let push = parse_request_line("git-receive-pack owner/doubled").unwrap();
        assert_eq!(push.repo(), "owner/doubled");
        assert!(parse_request_line("git-upload-pack owner/doubled").is_err());
    }

    /// Contract stamp: ADR-016 §2 (`host=` and `version=2` extras are
    /// accepted verbatim; unknown extras are accepted and ignored);
    /// core/w2-native-transport.
    #[test]
    fn extras_are_accepted_verbatim() {
        let line = parse_request_line(
            "git-upload-pack owner/doubled\0host=example.com\0\0version=2\0extra=whatever\0",
        )
        .unwrap();
        assert_eq!(line.repo(), "owner/doubled");
    }

    /// Contract stamp: ADR-016 §2 (malformed lines fail before any
    /// advertisement); core/w2-native-transport.
    #[test]
    fn an_unknown_service_fails() {
        for line in [
            "git-upload-archive owner/doubled\0host=x",
            "nope owner/repo",
        ] {
            let error = parse_request_line(line).unwrap_err();
            assert!(
                matches!(error, RequestLineError::ServiceUnknown(_)),
                "{line}: {error}"
            );
            assert!(
                error.decline_message().is_some(),
                "the decline is the clear error"
            );
        }
    }

    /// Contract stamp: backend.md §repo-id grammar / ADR-008 (a
    /// grammar-invalid id is rejected on the serving paths via the
    /// collapsed generic denial — no distinct invalid wire error);
    /// core/w2-native-transport.
    #[test]
    fn a_grammar_invalid_repo_id_fails_as_the_collapsed_denial() {
        for repo in ["", "UPPER/x", "owner/", ".", "owner/n a m e"] {
            let payload = format!("git-upload-pack {repo}\0\0version=2");
            let error = parse_request_line(&payload).unwrap_err();
            match &error {
                RequestLineError::RepoInvalid { repo: id, .. } => {
                    assert_eq!(id, repo);
                }
                other => panic!("{repo}: expected RepoInvalid, got {other:?}"),
            }
            assert!(error.decline_message().is_none());
        }
    }

    /// Contract stamp: ADR-003 (the V0/V1 decline — fetch clients that
    /// cannot speak V2 get a clear pkt-line error); ADR-016 §2 (a fetch
    /// line without `version=2` or with an unrecognized token is the
    /// same decline path); core/w2-native-transport.
    #[test]
    fn a_fetch_line_without_or_with_an_unrecognized_version_token_declines() {
        let without = parse_request_line("git-upload-pack owner/doubled\0host=x\0").unwrap_err();
        assert!(matches!(
            without,
            RequestLineError::VersionDeclined { token: None }
        ));
        let unrecognized =
            parse_request_line("git-upload-pack owner/doubled\0host=x\0version=1").unwrap_err();
        match &unrecognized {
            RequestLineError::VersionDeclined { token } => {
                assert_eq!(token.as_deref(), Some("1"));
            }
            other => panic!("expected VersionDeclined, got {other:?}"),
        }
        assert!(without.decline_message().is_some());
    }

    /// The line shape is closed: no space, an empty payload, and NUL
    /// before the service/repo pair are all malformed.
    #[test]
    fn deformed_lines_are_malformed() {
        for payload in ["git-upload-pack", "", "\0host=x\0version=2"] {
            assert!(
                matches!(
                    parse_request_line(payload),
                    Err(RequestLineError::Malformed(_))
                ),
                "{payload:?} is malformed"
            );
        }
    }

    /// The wire reader rides the byte stream honestly: the header fills
    /// across dribbled reads, the frame parses to the same line the
    /// grammar pins, and a byte-less clean close reads as `None` (the
    /// quiet session end).
    ///
    /// Contract stamp: ADR-016 §2 (the frame is the session boundary —
    /// exact bounded reads, no read-ahead); core/w2-native-transport.
    #[tokio::test]
    async fn the_wire_reader_serves_the_full_frame_and_the_quiet_close() {
        use tokio::io::AsyncWriteExt;

        let (mut client, mut server) = tokio::io::duplex(64);
        let payload = "git-receive-pack owner/doubled\0host=x\0";
        let mut frame = format!("{:04x}", payload.len() + 4).into_bytes();
        frame.extend_from_slice(payload.as_bytes());
        let driver = tokio::spawn(async move {
            // Dribble: the header rides two reads, the payload another.
            client.write_all(&frame[..2]).await.unwrap();
            client.write_all(&frame[2..4]).await.unwrap();
            client.write_all(&frame[4..]).await.unwrap();
            client.shutdown().await.unwrap();
        });
        let line = read_request_line(&mut server)
            .await
            .unwrap()
            .expect("the line parses once the frame is whole");
        assert_eq!(line.repo(), "owner/doubled");
        assert_eq!(line.service(), Service::ReceivePack);
        driver.await.unwrap();

        // A clean close before any byte: the quiet session end.
        let (mut client, mut server) = tokio::io::duplex(64);
        client.shutdown().await.unwrap();
        drop(client);
        assert!(read_request_line(&mut server).await.unwrap().is_none());
    }

    /// The wire reader's byte-level fail-closed arms (ADR-005 §4's
    /// malformed-frame class): every off-grammar frame — a truncated
    /// header, a non-hex length, a below-minimum length, a line over
    /// the byte cap, a truncated payload, a non-UTF-8 payload — ends
    /// the establishment with the typed `Malformed` error naming the
    /// shape, before the session could exist.
    ///
    /// Contract stamp: convention 11 (the cap is the rejection surface)
    /// + review 005's ingress-coverage read (the reader arms were the
    /// fuzz-relevant gap the parse-shape tests did not reach).
    #[tokio::test]
    async fn the_wire_reader_fails_closed_on_malformed_frames() {
        use tokio::io::AsyncWriteExt;

        for (why, bytes, expected) in [
            ("a truncated header", &b"001"[..], "header was truncated"),
            ("a non-hex pkt-line header", &b"zzzz"[..], "non-hex"),
            (
                "a below-minimum length",
                &b"0002"[..],
                "invalid pkt-line length",
            ),
            ("a header over the byte cap", &b"ffff"[..], "byte cap"),
            (
                "a truncated payload",
                &b"0010\x01\x02\x03\x04\x05"[..],
                "the request line was truncated",
            ),
            ("a non-UTF-8 payload", &b"0006\xff\xfe"[..], "not UTF-8"),
        ] {
            let (mut client, mut server) = tokio::io::duplex(64);
            client.write_all(bytes).await.unwrap();
            client.shutdown().await.unwrap();
            drop(client);
            match read_request_line(&mut server).await {
                Err(RequestLineError::Malformed(message)) => {
                    assert!(message.contains(expected), "{why}: {message}");
                }
                other => panic!("{why}: expected Malformed, got {other:?}"),
            }
        }
    }
}