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
//! One `git-lfs-transfer` connection, from the capability advertisement to
//! `quit`.
//!
//! Nothing here owns a socket: [`Session::serve`] takes `&mut dyn BufRead` and
//! `&mut dyn Write`, the same shape `gunnar-wire`'s state machines take, so it
//! can be driven from a unit test with no SSH in sight and from a russh channel
//! in production without knowing the difference.
//!
//! # The invariant this module exists to hold
//!
//! **No allocation is ever sized from an object.** Every byte of every object,
//! in both directions, moves through a buffer of [`CHUNK_BYTES`]. The size a
//! client declares is used to *refuse* and to *verify*, never to reserve. That
//! is asserted against real process RSS in `tests/streaming.rs`, because
//! "it streams" is precisely the claim a passing unit test cannot make.
//!
//! # Command order
//!
//! The first command **must** be `version`. The reference server accepts
//! commands in any order; gunnar does not, because a session that transferred
//! objects before any protocol version was agreed has no contract to have
//! served them under. The real client always negotiates first
//! (`PktlineConnection.Start`), so this costs nothing and closes a case nobody
//! would otherwise have decided.
use std::io::{BufRead, Write};
use gunnar_pktline::Packet;
use crate::allowance::Allowance;
use crate::batch::plan_object;
use crate::error::{Error, Result};
use crate::ledger::NamespaceAllowance;
use crate::limits::{Limits, CHUNK_BYTES};
use crate::oid::Oid;
use crate::operation::Operation;
use crate::store::LfsStore;
use crate::verify::VerifyingSink;
use crate::wire::{self, SectionEnd};
/// What a session did. Returned so the caller can log applied output, the
/// bytes and objects that really moved, rather than "a session happened".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Summary {
/// Commands dispatched, including the ones refused.
pub commands: u64,
/// `batch` requests answered.
pub batches: u64,
/// Objects named across every `batch`.
pub batch_objects: u64,
/// Objects streamed out by `get-object`.
pub objects_sent: u64,
/// Objects committed by `put-object`.
pub objects_received: u64,
/// Object payload bytes written to the client.
pub bytes_sent: u64,
/// Object payload bytes read from the client and committed.
pub bytes_received: u64,
/// Did the client say `quit`, or did the stream simply end?
pub clean_quit: bool,
}
/// A `git-lfs-transfer` session over one repository.
pub struct Session<'a> {
store: &'a dyn LfsStore,
operation: Operation,
limits: Limits,
/// The tenant's remaining plan, or `None` on a box that caps nobody — the
/// community appliance, and every namespace with no signed fact. See
/// [`Allowance`] for the number and [`NamespaceAllowance`] for why the door
/// needs to know what else is uploading against the same namespace.
allowance: Option<NamespaceAllowance>,
/// **Bytes this session has already committed against [`Self::allowance`].**
///
/// **No longer what the DOOR reads.** `put-object` charges the namespace's
/// ledger, which counts this session's own objects and every other
/// transfer's, so the running total below is subsumed by a number that is
/// also right when two sessions are open at once. What it is still for is
/// the `batch` answer, which is advisory and per session by nature — see
/// [`crate::batch::plan_object`].
///
/// A `Cell` because [`Session::serve`] takes `&self` — the whole state
/// machine does, so it can be driven from a test with no interior mutability
/// anywhere — and because one session is one channel on one thread: there is
/// no second writer to race with, and an `AtomicU64` would be a lock nobody
/// contends spelled as if somebody might.
///
/// It has to exist at all because a session is not one object. `put-object`
/// may repeat until `quit`, so an allowance checked only per object would
/// let a tenant with 1 GiB left upload twenty 8 GiB objects — each one
/// individually under every limit on the box. `tests/namespace_allowance.rs`
/// is the assertion that a per-object check could not pass.
taken: std::cell::Cell<u64>,
}
impl<'a> Session<'a> {
/// A session serving `store` for `operation`.
///
/// **`operation` is the one the SSH exec line named**, which is the one the
/// grant check in `gunnar_ssh::command` already used to demand Read or
/// Write. It is passed in rather than re-parsed here so there is one
/// authorisation decision, not two (one implementation).
pub fn new(store: &'a dyn LfsStore, operation: Operation) -> Self {
Session {
store,
operation,
limits: Limits::default(),
allowance: None,
taken: std::cell::Cell::new(0),
}
}
/// The same session with different limits.
pub fn with_limits(mut self, limits: Limits) -> Self {
self.limits = limits;
self
}
/// The limits in force.
pub fn limits(&self) -> Limits {
self.limits
}
/// **The same session, bounded by the tenant's remaining plan.**
///
/// Not a [`Limits`] field, though it is also a byte count that refuses an
/// upload: `Limits` is `Copy`, one set of numbers for the whole box, and is
/// carried on `WireConfig` from the operator's command line. This is a
/// different number for every namespace, is re-measured for every transfer,
/// and carries a sentence composed from a signed entitlement fact. Folding
/// them into one struct would make the operator's flags look tenant-scoped
/// and the tenant's plan look like a server default.
///
/// Absent on a community build and on any namespace with no cap — see
/// [`Allowance`] on why "no allowance" and "an allowance of 0" are
/// deliberately different states.
pub fn with_allowance(mut self, allowance: NamespaceAllowance) -> Self {
self.allowance = Some(allowance);
self
}
/// The tenant's remaining plan **as measured**, if this session has one —
/// for a log line and for the advisory `batch` answer.
///
/// Deliberately the inner [`Allowance`] and not the
/// [`NamespaceAllowance`]: this is the reporting accessor, it cannot
/// charge, and a caller about to take bytes must go through
/// [`NamespaceAllowance::admit`] instead. Returning the number that was
/// measured is also what a reader of `lfs_allowance_bytes=` in the
/// transport's log wants — the plan's room, not the plan's room minus
/// whatever a sibling connection happened to be doing at that instant.
pub fn allowance(&self) -> Option<&Allowance> {
self.allowance.as_ref().map(NamespaceAllowance::allowance)
}
/// Serve the connection to completion.
///
/// Returns `Err` only for failures that end the session: I/O and framing.
/// Everything else has already been answered with a status code on the wire
/// and is counted in the [`Summary`].
pub fn serve(&self, input: &mut dyn BufRead, output: &mut dyn Write) -> Result<Summary> {
let mut summary = Summary::default();
wire::write_capabilities(output)?;
output.flush()?;
let mut negotiated = false;
loop {
let packet = match gunnar_pktline::read_packet(input)? {
Some(packet) => packet,
// The client closed without `quit`. Legal, if impolite: git-lfs
// does it when a transfer errors out. Not an error here.
None => return Ok(summary),
};
let line = match packet {
Packet::Data(data) => data,
Packet::Flush => {
// A bare flush where a command belongs. Answered rather
// than treated as end-of-stream, matching the reference
// server, so a confused client gets a diagnostic.
wire::write_error(output, 400, "expected a command, got a flush-pkt")?;
output.flush()?;
continue;
}
other => {
return Err(Error::Framing(format!(
"unexpected {other:?} where a command belongs"
)))
}
};
let line =
String::from_utf8_lossy(line.strip_suffix(b"\n").unwrap_or(&line)).to_string();
let (verb, argument) = match line.split_once(' ') {
Some((verb, rest)) => (verb, rest),
None => (line.as_str(), ""),
};
summary.commands += 1;
if !negotiated && verb != "version" {
tracing::warn!(
target: crate::LFS_TARGET,
%verb,
"a command arrived before the protocol version was negotiated"
);
wire::write_error(output, 400, "the first command must be `version`")?;
output.flush()?;
continue;
}
let outcome = self.dispatch(verb, argument, input, output, &mut summary);
match outcome {
Ok(Flow::Continue) => {
if verb == "version" {
negotiated = true;
}
}
Ok(Flow::Quit) => {
summary.clean_quit = true;
output.flush()?;
return Ok(summary);
}
Err(err) if err.is_fatal() => {
// A fatal failure that still knows what it decided says so
// before it goes. `write_error` is allowed to fail here and
// is not allowed to replace `err`: the commonest way to
// reach this line at all is a peer that has already gone,
// and "could not tell you about the real error" is not the
// fact worth keeping. See `Error::final_report`.
if let Some((status, message)) = err.final_report() {
let _ = wire::write_error(output, status, &message);
let _ = output.flush();
}
tracing::warn!(target: crate::LFS_TARGET, %verb, %err, "the lfs session failed");
return Err(err);
}
Err(err) => {
tracing::debug!(target: crate::LFS_TARGET, %verb, status = err.status(), %err, "refused");
wire::write_error(output, err.status(), &err.client_message())?;
}
}
output.flush()?;
}
}
fn dispatch(
&self,
verb: &str,
argument: &str,
input: &mut dyn BufRead,
output: &mut dyn Write,
summary: &mut Summary,
) -> Result<Flow> {
match verb {
"version" => self.version(argument, input, output),
"batch" => self.batch(input, output, summary),
"get-object" => self.get_object(argument, input, output, summary),
"put-object" => self.put_object(argument, input, output, summary),
"verify-object" => self.verify_object(argument, input, output),
// The LFS design's optional half. See `wire::CAPABILITIES` for why 501 and not
// 405: git-lfs turns 501 into "locking is not implemented here" and
// disables lock verification silently, which is the true statement.
"lock" | "unlock" | "list-lock" | "list-locks" => {
let _ = wire::read_args(input, self.limits.max_args, "a lock command")?;
Err(Error::NotImplemented(format!(
"`{verb}` is not implemented: this server advertises no `locking` capability"
)))
}
"quit" => {
let _ = wire::read_args(input, self.limits.max_args, "quit")?;
// No delim. `ReadStatus`, the only reader `quit` uses, calls a
// delim `unexpected data`.
wire::write_status(output, 200, &[])?;
Ok(Flow::Quit)
}
// Deliberately not draining an unknown command's section: gunnar
// cannot know whether it has one, and guessing wrong swallows the
// next real command. The stray argument lines are each answered
// `400` in turn and the stream resynchronises at the next flush.
other => Err(Error::BadRequest(format!("unknown command `{other}`"))),
}
}
/// A command whose section ended with the **wrong terminator**, answered
/// with a 400 rather than killed with a [`Error::Framing`].
///
/// `Framing` means *the pkt-line boundary is lost and there is no way to
/// find the next command*. That was not true at any of the four call sites
/// that used it here: `read_args` had already consumed the section up to
/// and including its terminator and told the caller which one arrived, so
/// the reader knew exactly where it stood. `Framing` is fatal, so a client
/// that ended `version` with a delim-pkt got an EOF where the protocol
/// defines a status code — the same shape as the quota refusal that
/// `refuse!`'s drain error replaced.
///
/// A delim-pkt promises a further section. It is read out here, because
/// **that**, and not the error type, is what keeps the stream in sync; and
/// if reading it out fails, sync really is lost and the fatal error is
/// honest. `read_lines` is the same drain the `hash-algo` refusal already
/// uses (one implementation).
fn wrong_terminator(
&self,
input: &mut dyn BufRead,
what: &'static str,
wanted: SectionEnd,
got: SectionEnd,
) -> Error {
if got == SectionEnd::Delim {
if let Err(err) = wire::read_lines(
input,
self.limits.max_batch_objects,
"a section this command does not define",
) {
return Error::Framing(format!(
"{what} ended with a delim-pkt and the section that followed could not be \
read out, so the stream is desynced: {err}"
));
}
}
Error::BadRequest(format!(
"{what} must end with a {}, not a {}",
wanted.name(),
got.name()
))
}
fn version(
&self,
argument: &str,
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<Flow> {
let (_, end) = wire::read_args(input, self.limits.max_args, "version")?;
if end != SectionEnd::Flush {
return Err(self.wrong_terminator(input, "version", SectionEnd::Flush, end));
}
if argument != wire::PROTOCOL_VERSION {
return Err(Error::BadRequest(format!(
"unsupported protocol version `{argument}`; this server speaks {}",
wire::PROTOCOL_VERSION
)));
}
wire::write_status_lines(output, 200, &[], &[])?;
Ok(Flow::Continue)
}
/// Answer a `batch`.
///
/// # A `download` batch answers `download` for every object, held or not
///
/// This is the one place gunnar deliberately departs from
/// `charmbracelet/git-lfs-transfer`, and it is not a guess. RESEARCHED
/// 2026-08-05, against the specification, the client's source and the
/// reference server's.
///
/// **The pure-SSH grammar cannot express a per-object error.** From
/// `docs/proposals/ssh_adapter.md`, which is where the pure-SSH protocol is
/// specified — the HTTP `docs/api/batch.md` does not govern it:
///
/// ```text
/// batch-oid-line = PKT-LINE(oid size action *(key=[value]) LF)
/// ```
///
/// There is no `error` production, no `code`, nowhere to put one. The
/// document closes the door twice more: *"If the server has no actions that
/// are valid for an object, it should be listed once in the response with
/// the `noop` action"*, and *"Unknown arguments should be ignored, as should
/// unknown key-value pairs in the `oid-line` production"* — so a server that
/// invented `error=404` would be **specified into being ignored**. The HTTP
/// API's per-object `{"error":{"code":404}}` with the batch still 200
/// (`docs/api/batch.md`) has no counterpart here. That is a real gap in the
/// pure-SSH protocol, not a gap in gunnar.
///
/// **And `noop` is read by the client as "nothing to do", not as "absent".**
/// `tq/ssh.go`, parsing the response: `if entries[2] == "noop" { continue }`,
/// which leaves the transfer with an empty `Actions` map; then
/// `tq/transfer_queue.go`, dispatching it:
///
/// ```text
/// } else if a == nil && manifest.standaloneTransferAgent == "" {
/// q.Skip(o.Size)
/// q.wait.Done()
/// }
/// ```
///
/// No error, no retry, no line printed. MEASURED against git-lfs 3.7.1: a
/// `git lfs fetch --all` for two objects of which gunnar held one printed
/// **"2 objects found, done."** and exited **0**, having fetched one. The
/// reference server does exactly this —
/// `BatchData(DownloadOperation, "download", "noop")` — so every deployed
/// LFS-over-SSH server has the same hole.
///
/// **`get-object` is where the protocol can still tell the truth.** It has a
/// `status-error-response`, and the client turns any non-2xx from it into a
/// reported failure for that object alone. So a download batch hands back
/// the `download` action unconditionally and [`Session::get_object`] answers
/// `404` for what is not here. That reaches, over SSH, precisely the outcome
/// git-lfs's own suite demands over HTTP (`t/t-fetch.sh`, "fetch with
/// missing object"): *"should return non-zero, but should also download all
/// the other valid files too"*. Pinned in
/// `tests/interop.rs::a_fetch_of_an_absent_object_fails_and_still_delivers_the_present_one`.
///
/// The cost, stated plainly: the client wraps a failed `get-object` in
/// `errors.NewRetriableError` unconditionally (`tq/ssh.go`), so it asks for
/// an absent object up to `lfs.transfer.maxretries` times — 8 by default —
/// before reporting it. Eight cheap round trips on an already-open channel,
/// in exchange for the fetch not lying. Refusing the whole batch instead
/// would report the failure in one trip and cost the client every *other*
/// object in it, which is the trade the HTTP API exists to avoid.
///
/// An **upload** batch is unchanged, and `noop` there is correct in its own
/// right: the server holds the bytes, so there is genuinely no action.
fn batch(
&self,
input: &mut dyn BufRead,
output: &mut dyn Write,
summary: &mut Summary,
) -> Result<Flow> {
let (args, end) = wire::read_args(input, self.limits.max_args, "the batch arguments")?;
if end != SectionEnd::Delim {
return Err(self.wrong_terminator(
input,
"the batch arguments",
SectionEnd::Delim,
end,
));
}
// The client sends `hash-algo=sha256` and refuses any other value in the
// response. An LFS oid is SHA-256 by definition; there is nothing else
// to agree about, and a server that quietly accepted `sha1` here would
// be filing objects under ids no client computes.
match wire::arg(&args, "hash-algo") {
None | Some("") | Some("sha256") => {}
Some(other) => {
let _ = wire::read_lines(input, self.limits.max_batch_objects, "the batch objects");
// 409, the code `docs/api/batch.md` names for exactly this.
// See [`Error::Conflict`].
return Err(Error::Conflict(format!(
"unsupported hash algorithm `{other}`; lfs oids are sha256"
)));
}
}
let lines = wire::read_lines(input, self.limits.max_batch_objects, "the batch objects")?;
summary.batches += 1;
summary.batch_objects += lines.len() as u64;
let mut response = Vec::with_capacity(lines.len());
for line in &lines {
let mut fields = line.split(' ');
let (Some(oid_text), Some(size_text)) = (fields.next(), fields.next()) else {
return Err(Error::BadRequest(format!(
"malformed batch line `{}`",
truncate(line)
)));
};
let oid = Oid::parse(oid_text)
.map_err(|err| Error::BadRequest(format!("batch line: {err}")))?;
let size: u64 = size_text.parse().map_err(|_| {
Error::BadRequest(format!(
"batch line: `{}` is not a size",
truncate(size_text)
))
})?;
// **One decision, two wires.** The quota, the store lookup and the
// ordering between them live in `crate::batch`, which the HTTP
// batch API routes through as well (one implementation). What differs is only
// what each grammar can *say* about the answer: this one has
// `upload | download | noop` and nothing else, so `AlreadyPresent`
// and `Missing` both come out `noop` here. See `batch::Plan`.
let plan = plan_object(
self.store,
&self.limits,
self.allowance(),
self.taken.get(),
self.operation,
&oid,
size,
)?;
response.push(format!("{oid} {size} {}", plan.ssh_action(self.operation)));
}
wire::write_status_lines(output, 200, &[], &response)?;
Ok(Flow::Continue)
}
fn get_object(
&self,
argument: &str,
input: &mut dyn BufRead,
output: &mut dyn Write,
summary: &mut Summary,
) -> Result<Flow> {
let (_, end) = wire::read_args(input, self.limits.max_args, "get-object")?;
if end != SectionEnd::Flush {
return Err(self.wrong_terminator(input, "get-object", SectionEnd::Flush, end));
}
if !self.operation.may_download() {
return Err(Error::NotAllowed(
"get-object is only available on a `download` connection".to_string(),
));
}
let oid =
Oid::parse(argument).map_err(|err| Error::BadRequest(format!("get-object: {err}")))?;
let Some(object) = self.store.open(&oid)? else {
return Err(Error::NotFound(format!(
"object {oid} is not on this server"
)));
};
// `size=` is mandatory and must precede the data: the client errors with
// "no size argument seen" before it reads a byte.
wire::write_text(output, &format!("status {}", 200))?;
wire::write_text(output, &format!("size={}", object.size))?;
wire::write_delim(output)?;
let mut reader = object.reader;
let mut framer = wire::DataFramer::new(output);
// `copy` with an explicit slab rather than `std::io::copy`: the slab
// size is the memory bound this module promises, and it must be stated
// here rather than inherited from whatever `copy` happens to pick.
let mut slab = vec![0u8; CHUNK_BYTES];
let mut sent = 0u64;
loop {
let n = reader.read(&mut slab)?;
if n == 0 {
break;
}
framer.write_all(&slab[..n])?;
sent += n as u64;
}
let framed = framer.finish()?;
wire::write_flush(output)?;
debug_assert_eq!(framed, sent);
if sent != object.size {
// The store lied about the size it just reported. The client has
// already been told `size=`, so the only honest thing left is to
// say so in the log. The wire is committed.
tracing::error!(
target: crate::LFS_TARGET,
%oid, declared = object.size, sent,
"the object store returned a different number of bytes than it declared"
);
}
summary.objects_sent += 1;
summary.bytes_sent += sent;
Ok(Flow::Continue)
}
fn put_object(
&self,
argument: &str,
input: &mut dyn BufRead,
output: &mut dyn Write,
summary: &mut Summary,
) -> Result<Flow> {
let (args, end) = wire::read_args(input, self.limits.max_args, "put-object")?;
// Everything that can be refused *before* the data section is read is
// refused here, but each refusal must still consume the object the
// client is already writing, or the next read finds object bytes where
// a command belongs. `refuse` does both, in that order.
//
// **The drain's failure must not become the answer.** `?` on the drain
// discarded the refusal that was already decided and returned the
// drain's own `Framing` error, which is fatal and carries no status —
// so a client that sent past the drain budget got a dropped channel
// instead of its 413. That budget is 128 KiB and the quota is 8 GiB, so
// it was every refusal an operator would ever configure a quota for.
// See [`Error::RefusedThenClosed`].
//
// **The macro is a thin wrapper over `drain_then`, not a second copy of
// it.** When the drain-swallows-the-refusal bug was fixed it was fixed
// *here*, in the macro, and the two refusals further down that drain by
// hand — `begin_write` failing, and the sink rejecting a chunk — were
// left spelled `self.drain_data_section(input)?`, still swallowing.
// Nothing reached them at the time. `Error::OutOfSpace` reaches both.
// One writer now, so there is no third copy to forget (one implementation).
macro_rules! refuse {
($err:expr) => {{
let err: Error = $err;
if end == SectionEnd::Delim {
return Err(self.drain_then(input, err));
}
return Err(err);
}};
}
if !self.operation.may_upload() {
refuse!(Error::NotAllowed(
"put-object is only available on an `upload` connection".to_string(),
));
}
let oid = match Oid::parse(argument) {
Ok(oid) => oid,
Err(err) => refuse!(Error::BadRequest(format!("put-object: {err}"))),
};
let Some(size_text) = wire::arg(&args, "size") else {
refuse!(Error::BadRequest(
"put-object requires a `size` argument".to_string()
));
};
let Ok(declared) = size_text.parse::<u64>() else {
refuse!(Error::BadRequest(format!(
"put-object: `{}` is not a size",
truncate(size_text)
)));
};
if declared > self.limits.max_object_bytes {
refuse!(Error::TooLarge(format!(
"object {oid} is {declared} bytes; this server accepts at most {}",
self.limits.max_object_bytes
)));
}
// **The tenant's own room, and this is the load-bearing copy of that
// check** for the same reason the line above is the load-bearing copy of
// the operator's: a client is free to skip `batch` entirely, so a door
// that only stood in `batch` would stand in front of the polite clients
// and nobody else.
//
// Counted against the NAMESPACE's ledger and not this session's
// `taken`, which is the fix for the concurrent half of the same
// question. `taken` answered "what have I committed" and every session
// could only ever answer it about itself, so two sessions opened at the
// same instant were both admitted against an empty namespace. The
// ledger counts this session's earlier objects too, so the
// split-into-legal-pieces refusal that `taken` used to make is not lost
// — see [`NamespaceAllowance::admit`].
//
// ⚠ AND IT IS CHARGED ON A NUMBER THE CLIENT CHOSE. The payload has not
// arrived, so `declared` is all there is to judge by, and `ledger.rs`
// is a monotone counter with nothing to give back — deliberately, and
// its module comment argues the case at length: a reservation that can
// be leaked locks a tenant out of quota they own, forever, on one
// crash. That argument holds, and it assumes the over-charge is
// "over-conservative, transient, and self-healing".
//
// A client that declares and then does not deliver breaks the
// assumption rather than the design. Nothing bytes back a lie, so see
// `charged_but_not_delivered` below: the object's refusal is fatal to
// the connection, which bounds a lying client to ONE charge per
// connection instead of one per round trip.
let charged = match &self.allowance {
Some(allowance) => {
if let Err(err) = allowance.admit(declared) {
refuse!(err);
}
true
}
None => false,
};
if end != SectionEnd::Delim {
return Err(Error::BadRequest(
"put-object must be followed by a delim-pkt and the object".to_string(),
));
}
let pending = match self.store.begin_write(&oid, declared) {
Ok(pending) => pending,
Err(err) => return Err(self.drain_then(input, err)),
};
let mut sink = VerifyingSink::new(pending, oid, declared);
loop {
match gunnar_pktline::expect_packet(input, "the object payload")? {
Packet::Flush => break,
Packet::Data(data) => {
if let Err(err) = sink.accept(&data) {
// Over-long already, or the store has no room: stop
// feeding it, discard what was written, and drain the
// rest so the session survives to answer.
drop(sink);
let refusal = self.drain_then(input, err);
return Err(self.charged_but_not_delivered(charged, declared, refusal));
}
}
other => {
return Err(Error::Framing(format!(
"unexpected {other:?} inside the object payload"
)))
}
}
}
let received = sink.received();
if let Err(err) = sink.commit() {
// The object did not turn out to be the one that was declared: too
// short, or the wrong hash. The allowance was charged for
// `declared` bytes and none of them landed.
return Err(self.charged_but_not_delivered(charged, declared, err));
}
summary.objects_received += 1;
summary.bytes_received += received;
// **After `commit`, never before.** What the allowance counts is bytes
// the tenant now HOLDS, and an object that failed verification or that
// the store refused holds nothing — charging for it would refuse a
// tenant's next legitimate upload on the strength of one that never
// landed. `received` and not `declared` for the same reason: the two are
// equal here only because `VerifyingSink` just proved they are.
self.taken.set(self.taken.get().saturating_add(received));
// Success carries no lines. `SuccessStatus()` in the reference server
// is the no-delim form, and the client reads this one with
// `ReadStatusWithLines`, which accepts either.
wire::write_status(output, 200, &[])?;
Ok(Flow::Continue)
}
fn verify_object(
&self,
argument: &str,
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<Flow> {
let (args, end) = wire::read_args(input, self.limits.max_args, "verify-object")?;
if end != SectionEnd::Flush {
return Err(self.wrong_terminator(input, "verify-object", SectionEnd::Flush, end));
}
if !self.operation.may_upload() {
return Err(Error::NotAllowed(
"verify-object is only available on an `upload` connection".to_string(),
));
}
let oid = Oid::parse(argument)
.map_err(|err| Error::BadRequest(format!("verify-object: {err}")))?;
let declared: u64 = wire::arg(&args, "size")
.ok_or_else(|| {
Error::BadRequest("verify-object requires a `size` argument".to_string())
})?
.parse()
.map_err(|_| Error::BadRequest("verify-object: malformed `size`".to_string()))?;
match self.store.size_of(&oid)? {
None => Err(Error::NotFound(format!(
"object {oid} was not stored by this server"
))),
Some(stored) if stored != declared => Err(Error::Corrupt(format!(
"object {oid} is {stored} bytes on this server, not {declared}"
))),
Some(_) => {
wire::write_status(output, 200, &[])?;
Ok(Flow::Continue)
}
}
}
/// **A client that charged the allowance and did not deliver does not get
/// to do it again on this connection.**
///
/// `put-object` admits against the size the client DECLARED, because the
/// payload has not arrived yet and there is nothing else to judge by, and
/// `ledger.rs` is a monotone counter with nothing to give back. Its module
/// comment makes that case well and the case rests on one assumption: that
/// the over-charge is "over-conservative, transient, and self-healing". A
/// declaration the client then does not honour is not any of those. The
/// refusal for a corrupt object is a 422, which is NOT fatal, so the same
/// connection could declare `max_object_bytes`, send garbage, be told 422,
/// and declare again — as fast as it could loop, charging a namespace's
/// whole plan without a byte of it landing. The counter is per NAMESPACE,
/// so what it exhausts is every other session in that namespace whose mark
/// was taken first, not only the liar's own.
///
/// This does not make the charge correct; it bounds it. One lie costs one
/// object's declaration and then the connection, which is exactly the size
/// of over-charge `ledger.rs` already reasons about and calls self-healing.
/// Charging what was RECEIVED instead — the obvious reading — would mean
/// admitting before the bytes are counted and settling afterwards, which is
/// a reservation, which is the hazard that file refuses by construction.
///
/// `RefusedThenClosed` and not a bare fatal error, because the client is
/// still owed the 422: `final_report` writes it before the session goes.
/// Untouched when no allowance was charged — a server with no plan has
/// nothing to exhaust, and ending its sessions over a corrupt object would
/// be a new refusal wearing this one's clothes.
fn charged_but_not_delivered(&self, charged: bool, declared: u64, refusal: Error) -> Error {
if !charged {
return refusal;
}
Error::RefusedThenClosed {
refusal: Box::new(refusal),
closing: Box::new(Error::Corrupt(format!(
"the namespace's allowance was charged {declared} bytes for an object that did not arrive; this connection does not get to declare another"
))),
}
}
/// Read out the object a refusal has already been decided for, and give back
/// the error to answer with.
///
/// **The refusal always survives.** If the drain fails, the two facts are
/// kept apart in [`Error::RefusedThenClosed`] — what the client is told, and
/// why the session then ends — rather than the drain's fatal `Framing`
/// replacing the status the client was owed. This is the one place that
/// decision is made; see the note on `put-object`'s `refuse!`.
fn drain_then(&self, input: &mut dyn BufRead, refusal: Error) -> Error {
match self.drain_data_section(input) {
Ok(()) => refusal,
Err(desync) => Error::RefusedThenClosed {
refusal: Box::new(refusal),
closing: Box::new(desync),
},
}
}
/// Read and discard an object the server has already refused.
///
/// Bounded by [`Limits::max_drain_bytes`]: past that the session ends,
/// because a client that keeps sending after a refusal is not one this
/// server owes an orderly conversation to. Memory is O(one pkt-line)
/// throughout; the packets are dropped as they arrive.
fn drain_data_section(&self, input: &mut dyn BufRead) -> Result<()> {
let mut drained = 0u64;
loop {
match gunnar_pktline::expect_packet(input, "a refused object payload")? {
Packet::Flush => return Ok(()),
Packet::Data(data) => {
drained += data.len() as u64;
if drained > self.limits.max_drain_bytes {
return Err(Error::Framing(format!(
"a refused upload kept sending past {} bytes; \
the session is closing rather than reading it out",
self.limits.max_drain_bytes
)));
}
}
other => {
return Err(Error::Framing(format!(
"unexpected {other:?} inside a refused object payload"
)))
}
}
}
}
}
enum Flow {
Continue,
Quit,
}
fn truncate(text: &str) -> String {
text.chars().take(64).collect()
}