max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_013vDpLixQiknHhfHiGxFWo7
4 files changed,
+449 insertions,
-35 deletions
| @@ -770,7 +770,7 @@ | |||
| 770 | 770 | ||
| 771 | 771 | [[package]] | |
| 772 | 772 | name = "kittygfx" | |
| 773 | - | version = "0.1.0" | |
| 773 | + | version = "0.1.1" | |
| 774 | 774 | dependencies = [ | |
| 775 | 775 | "base64", | |
| 776 | 776 | "tracing", | |
| @@ -2383,20 +2383,12 @@ | |||
| 2383 | 2383 | ] | |
| 2384 | 2384 | ||
| 2385 | 2385 | [[patch.unused]] | |
| 2386 | - | name = "kberg" | |
| 2387 | - | version = "0.1.0" | |
| 2386 | + | name = "synckit-client" | |
| 2387 | + | version = "0.10.0" | |
| 2388 | 2388 | ||
| 2389 | 2389 | [[patch.unused]] | |
| 2390 | - | name = "ops-status" | |
| 2391 | - | version = "0.1.0" | |
| 2392 | - | ||
| 2393 | - | [[patch.unused]] | |
| 2394 | - | name = "painhours" | |
| 2395 | - | version = "0.1.0" | |
| 2396 | - | ||
| 2397 | - | [[patch.unused]] | |
| 2398 | - | name = "tagtree" | |
| 2399 | - | version = "0.4.1" | |
| 2390 | + | name = "synckit-config" | |
| 2391 | + | version = "0.2.0" | |
| 2400 | 2392 | ||
| 2401 | 2393 | [[patch.unused]] | |
| 2402 | 2394 | name = "quasi-axum" | |
| @@ -2439,9 +2431,17 @@ | |||
| 2439 | 2431 | version = "0.7.0" | |
| 2440 | 2432 | ||
| 2441 | 2433 | [[patch.unused]] | |
| 2442 | - | name = "synckit-client" | |
| 2443 | - | version = "0.10.0" | |
| 2434 | + | name = "kberg" | |
| 2435 | + | version = "0.1.0" | |
| 2444 | 2436 | ||
| 2445 | 2437 | [[patch.unused]] | |
| 2446 | - | name = "synckit-config" | |
| 2447 | - | version = "0.2.0" | |
| 2438 | + | name = "ops-status" | |
| 2439 | + | version = "0.1.0" | |
| 2440 | + | ||
| 2441 | + | [[patch.unused]] | |
| 2442 | + | name = "painhours" | |
| 2443 | + | version = "0.1.0" | |
| 2444 | + | ||
| 2445 | + | [[patch.unused]] | |
| 2446 | + | name = "tagtree" | |
| 2447 | + | version = "0.4.1" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "kittygfx" | |
| 3 | - | version = "0.1.0" | |
| 3 | + | version = "0.1.1" | |
| 4 | 4 | description = "Rendering-agnostic implementation of the Kitty terminal graphics protocol" | |
| 5 | 5 | edition.workspace = true | |
| 6 | 6 | rust-version.workspace = true |
| @@ -344,7 +344,7 @@ | |||
| 344 | 344 | if !control.more_chunks && !self.partial.contains_key(&key) { | |
| 345 | 345 | // Single-shot — no reassembly needed. Decode into a fresh Vec | |
| 346 | 346 | // sized to the payload; the caller owns the result. | |
| 347 | - | if decoded_upper_bound(payload_b64) > MAX_IN_FLIGHT_BYTES { | |
| 347 | + | if over_budget(decoded_upper_bound(payload_b64)) { | |
| 348 | 348 | return None; | |
| 349 | 349 | } | |
| 350 | 350 | let payload = if payload_b64.is_empty() { | |
| @@ -382,7 +382,7 @@ | |||
| 382 | 382 | // Before the resize, not after: the arithmetic below runs over a | |
| 383 | 383 | // length the writer chose, and the resize is what commits it. | |
| 384 | 384 | let after = held_elsewhere.saturating_add(start).saturating_add(extra); | |
| 385 | - | if entry.overflowed || after > MAX_IN_FLIGHT_BYTES { | |
| 385 | + | if entry.overflowed || over_budget(after) { | |
| 386 | 386 | entry.overflowed = true; | |
| 387 | 387 | } else { | |
| 388 | 388 | entry.payload.resize(start + extra, 0); | |
| @@ -460,6 +460,17 @@ | |||
| 460 | 460 | payload_b64.len().div_ceil(4) * 3 | |
| 461 | 461 | } | |
| 462 | 462 | ||
| 463 | + | /// Whether `bytes` of decoded payload is more than the in-flight budget allows. | |
| 464 | + | /// | |
| 465 | + | /// A named decision rather than two inline `>` comparisons, because the | |
| 466 | + | /// boundary is only reachable through 89 MB of base64 otherwise: the mutant | |
| 467 | + | /// that widens it to `>=` differs from the original at exactly | |
| 468 | + | /// [`MAX_IN_FLIGHT_BYTES`] and nowhere else. Here a test can state the boundary | |
| 469 | + | /// directly, and both callers get it from one place. | |
| 470 | + | fn over_budget(bytes: usize) -> bool { | |
| 471 | + | bytes > MAX_IN_FLIGHT_BYTES | |
| 472 | + | } | |
| 473 | + | ||
| 463 | 474 | /// Split at the first `sep`. Returns `(before, None)` when no `sep` byte is | |
| 464 | 475 | /// present — the caller uses that to distinguish "empty payload after `;`" | |
| 465 | 476 | /// from "no `;` at all". | |
| @@ -558,6 +569,239 @@ | |||
| 558 | 569 | } | |
| 559 | 570 | } | |
| 560 | 571 | ||
| 572 | + | // ---- the bounds as numbers, and the accounting that reads them ------ | |
| 573 | + | // | |
| 574 | + | // A cap the parser stays self-consistent under is invisible to every | |
| 575 | + | // behavioural test: the parser enforces whatever number is there. These | |
| 576 | + | // state the numbers and the arithmetic a second time, which is the only | |
| 577 | + | // way a change to either is a disagreement rather than a new baseline. | |
| 578 | + | ||
| 579 | + | #[test] | |
| 580 | + | fn the_in_flight_budget_is_sixty_four_mebibytes() { | |
| 581 | + | // Written as a plain decimal so that a mutant rewriting the product in | |
| 582 | + | // the source has nothing here to agree with. | |
| 583 | + | assert_eq!(MAX_IN_FLIGHT_BYTES, 67_108_864); | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | #[test] | |
| 587 | + | fn the_budget_boundary_admits_exactly_the_budget() { | |
| 588 | + | // `>` vs `>=` disagree at this one value and nowhere else, and the | |
| 589 | + | // input that would reach it through feed() is 89 MB of base64. | |
| 590 | + | assert!(!over_budget(MAX_IN_FLIGHT_BYTES)); | |
| 591 | + | assert!(over_budget(MAX_IN_FLIGHT_BYTES + 1)); | |
| 592 | + | assert!(!over_budget(MAX_IN_FLIGHT_BYTES - 1)); | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | /// A parser holding one unfinished chunked transmission of `payload`. | |
| 596 | + | fn parser_holding(payload: &[u8]) -> Parser { | |
| 597 | + | let mut p = Parser::new(); | |
| 598 | + | let body = format!("Ga=T,f=32,s=64,v=64,i=7,m=1;{}", b64(payload)); | |
| 599 | + | assert!( | |
| 600 | + | p.feed(body.as_bytes()).is_none(), | |
| 601 | + | "an opening chunk emits nothing" | |
| 602 | + | ); | |
| 603 | + | p | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | #[test] | |
| 607 | + | fn pending_bytes_counts_the_map_spine_and_every_payload() { | |
| 608 | + | let p = parser_holding(&[0xAB; 3000]); | |
| 609 | + | // The arithmetic stated twice: a mutant that turns the product into a | |
| 610 | + | // sum, or the whole body into a constant, disagrees with this. | |
| 611 | + | let expected = p.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>() | |
| 612 | + | + p.partial | |
| 613 | + | .values() | |
| 614 | + | .map(|x| x.payload.capacity()) | |
| 615 | + | .sum::<usize>(); | |
| 616 | + | assert_eq!(p.pending_bytes(), expected); | |
| 617 | + | assert!( | |
| 618 | + | p.pending_bytes() >= 3000, | |
| 619 | + | "a transmission holding 3000 bytes cannot report fewer" | |
| 620 | + | ); | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | #[test] | |
| 624 | + | fn in_flight_bytes_excluding_leaves_out_the_named_transmission_only() { | |
| 625 | + | let mut p = Parser::new(); | |
| 626 | + | for (id, len) in [(7u32, 3000usize), (9, 6000)] { | |
| 627 | + | let body = format!("Ga=T,f=32,s=64,v=64,i={id},m=1;{}", b64(&vec![0xCD; len])); | |
| 628 | + | assert!(p.feed(body.as_bytes()).is_none()); | |
| 629 | + | } | |
| 630 | + | // Two transmissions of different sizes, so excluding the wrong one, or | |
| 631 | + | // returning a constant, gives a different answer from all of these. | |
| 632 | + | let seven = p.in_flight_bytes_excluding(PartialKey::ById(7)); | |
| 633 | + | let nine = p.in_flight_bytes_excluding(PartialKey::ById(9)); | |
| 634 | + | assert_eq!(seven, 6000, "excluding 7 must leave 9's bytes"); | |
| 635 | + | assert_eq!(nine, 3000, "excluding 9 must leave 7's bytes"); | |
| 636 | + | assert_eq!( | |
| 637 | + | p.in_flight_bytes_excluding(PartialKey::Anon), | |
| 638 | + | 9000, | |
| 639 | + | "excluding a key that is not there leaves both" | |
| 640 | + | ); | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | #[test] | |
| 644 | + | fn the_map_never_holds_more_than_the_in_flight_cap() { | |
| 645 | + | // Eviction is what keeps an id-space attack bounded, and nothing else | |
| 646 | + | // in the suite makes the map grow past the cap. | |
| 647 | + | let mut p = Parser::new(); | |
| 648 | + | let over = MAX_IN_FLIGHT_TRANSMISSIONS + 24; | |
| 649 | + | for id in 0..over { | |
| 650 | + | let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x11; 12])); | |
| 651 | + | assert!(p.feed(body.as_bytes()).is_none()); | |
| 652 | + | assert!( | |
| 653 | + | p.pending_transmissions() <= MAX_IN_FLIGHT_TRANSMISSIONS, | |
| 654 | + | "{} transmissions in flight after opening {}", | |
| 655 | + | p.pending_transmissions(), | |
| 656 | + | id + 1 | |
| 657 | + | ); | |
| 658 | + | } | |
| 659 | + | assert_eq!( | |
| 660 | + | p.pending_transmissions(), | |
| 661 | + | MAX_IN_FLIGHT_TRANSMISSIONS, | |
| 662 | + | "{over} openings must leave exactly the cap behind" | |
| 663 | + | ); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // ---- control fields nothing was reading ----------------------------- | |
| 667 | + | ||
| 668 | + | #[test] | |
| 669 | + | fn every_medium_spelling_parses_to_its_own_variant() { | |
| 670 | + | // The media the parser does not act on are still parsed, and a deleted | |
| 671 | + | // arm makes one of them read as "no medium given", which the query | |
| 672 | + | // answer treats as Direct and says OK to. | |
| 673 | + | for (spelling, want) in [ | |
| 674 | + | ("d", Medium::Direct), | |
| 675 | + | ("f", Medium::File), | |
| 676 | + | ("t", Medium::TempFile), | |
| 677 | + | ("s", Medium::Shm), | |
| 678 | + | ] { | |
| 679 | + | let c = parse_control(format!("a=q,t={spelling}").as_bytes()) | |
| 680 | + | .unwrap_or_else(|| panic!("t={spelling} did not parse")); | |
| 681 | + | assert_eq!(c.medium, Some(want), "t={spelling}"); | |
| 682 | + | } | |
| 683 | + | assert_eq!( | |
| 684 | + | parse_control(b"a=q,t=z").unwrap().medium, | |
| 685 | + | None, | |
| 686 | + | "an unknown medium is None, not a default" | |
| 687 | + | ); | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | #[test] | |
| 691 | + | fn a_body_with_no_m_field_is_its_own_last_chunk() { | |
| 692 | + | // The fallback at the end of parse_control. Without it a single-shot | |
| 693 | + | // body reports itself unfinished. | |
| 694 | + | let c = parse_control(b"a=T,f=32,s=1,v=1").unwrap(); | |
| 695 | + | assert!(c.last_chunk, "a body with no m= is complete by itself"); | |
| 696 | + | assert!(!c.more_chunks); | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | #[test] | |
| 700 | + | fn m_zero_sets_last_chunk_even_when_m_one_came_first() { | |
| 701 | + | // The only input that separates the `m=0` arm from the fallback: the | |
| 702 | + | // fallback cannot fire, because more_chunks is set. | |
| 703 | + | let c = parse_control(b"a=T,m=1,m=0").unwrap(); | |
| 704 | + | assert!(c.last_chunk, "m=0 says last chunk in its own right"); | |
| 705 | + | assert!(c.more_chunks); | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | #[test] | |
| 709 | + | fn the_clock_ticks_once_per_accepted_chunk() { | |
| 710 | + | // The clock is what orders the map for eviction. Frozen, every entry | |
| 711 | + | // carries the same last_advanced and eviction picks by hash order | |
| 712 | + | // instead of by age -- which no assertion about *how many* entries | |
| 713 | + | // survive can see. | |
| 714 | + | let mut p = Parser::new(); | |
| 715 | + | for id in 0..5u32 { | |
| 716 | + | let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x22; 12])); | |
| 717 | + | assert!(p.feed(body.as_bytes()).is_none()); | |
| 718 | + | } | |
| 719 | + | assert_eq!(p.clock, 5, "one tick per chunk accepted, and no more"); | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | #[test] | |
| 723 | + | fn eviction_drops_the_least_recently_advanced_transmission() { | |
| 724 | + | // Not the oldest-opened: a transmission that is still being fed is | |
| 725 | + | // live, and dropping it in favour of one that has sat untouched is the | |
| 726 | + | // behaviour the clock exists to prevent. | |
| 727 | + | let mut p = Parser::new(); | |
| 728 | + | for id in 0..MAX_IN_FLIGHT_TRANSMISSIONS as u32 { | |
| 729 | + | let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x33; 12])); | |
| 730 | + | assert!(p.feed(body.as_bytes()).is_none()); | |
| 731 | + | } | |
| 732 | + | // Advance the one opened first, so it is no longer the stalest. | |
| 733 | + | assert!( | |
| 734 | + | p.feed(format!("Gi=0,m=1;{}", b64(&[0x44; 12])).as_bytes()) | |
| 735 | + | .is_none() | |
| 736 | + | ); | |
| 737 | + | // Opening one more has to evict, and 1 is now the stalest. | |
| 738 | + | assert!( | |
| 739 | + | p.feed(format!("Ga=T,f=32,s=8,v=8,i=99,m=1;{}", b64(&[0x55; 12])).as_bytes()) | |
| 740 | + | .is_none() | |
| 741 | + | ); | |
| 742 | + | assert!( | |
| 743 | + | p.partial.contains_key(&PartialKey::ById(0)), | |
| 744 | + | "the transmission that was still being fed must survive" | |
| 745 | + | ); | |
| 746 | + | assert!( | |
| 747 | + | !p.partial.contains_key(&PartialKey::ById(1)), | |
| 748 | + | "the least recently advanced one is what goes" | |
| 749 | + | ); | |
| 750 | + | assert_eq!(p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS); | |
| 751 | + | } | |
| 752 | + | ||
| 753 | + | #[test] | |
| 754 | + | fn a_later_chunk_can_carry_fields_the_opener_left_out() { | |
| 755 | + | // merge_control fills the STORED control from the current chunk, so | |
| 756 | + | // this is the direction that observes it: an opener that named no | |
| 757 | + | // format, and a closer that does. | |
| 758 | + | let payload = [0x6Eu8; 96]; | |
| 759 | + | let encoded = b64(&payload); | |
| 760 | + | let (head, tail) = encoded.split_at(16); | |
| 761 | + | let mut p = Parser::new(); | |
| 762 | + | assert!(p.feed(format!("Ga=T,i=12,m=1;{head}").as_bytes()).is_none()); | |
| 763 | + | let cmd = p | |
| 764 | + | .feed(format!("Gi=12,f=32,s=4,v=8,C=1,m=0;{tail}").as_bytes()) | |
| 765 | + | .expect("the closing chunk completes the transmission"); | |
| 766 | + | let (control, got) = transmit(cmd); | |
| 767 | + | assert_eq!(got, payload); | |
| 768 | + | assert_eq!( | |
| 769 | + | control.format, | |
| 770 | + | Some(Format::Rgba), | |
| 771 | + | "f= arrived on the closing chunk and must be kept" | |
| 772 | + | ); | |
| 773 | + | assert_eq!(control.width_px, Some(4)); | |
| 774 | + | assert_eq!(control.height_px, Some(8)); | |
| 775 | + | assert!(control.no_cursor_move); | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | #[test] | |
| 779 | + | fn a_continuation_chunk_inherits_the_opening_control() { | |
| 780 | + | // merge_control is what carries format and geometry from the opening | |
| 781 | + | // chunk to the command; a later chunk repeats only `i=` and `m=`. | |
| 782 | + | let payload = [0x7Fu8; 96]; | |
| 783 | + | let encoded = b64(&payload); | |
| 784 | + | let (head, tail) = encoded.split_at(16); | |
| 785 | + | let mut p = Parser::new(); | |
| 786 | + | assert!( | |
| 787 | + | p.feed(format!("Ga=T,f=32,s=4,v=8,C=1,i=11,m=1;{head}").as_bytes()) | |
| 788 | + | .is_none() | |
| 789 | + | ); | |
| 790 | + | let cmd = p | |
| 791 | + | .feed(format!("Gi=11,m=0;{tail}").as_bytes()) | |
| 792 | + | .expect("the closing chunk completes the transmission"); | |
| 793 | + | let (control, got) = transmit(cmd); | |
| 794 | + | assert_eq!(got, payload); | |
| 795 | + | assert_eq!( | |
| 796 | + | control.format, | |
| 797 | + | Some(Format::Rgba), | |
| 798 | + | "f= came from the opener" | |
| 799 | + | ); | |
| 800 | + | assert_eq!(control.width_px, Some(4), "s= came from the opener"); | |
| 801 | + | assert_eq!(control.height_px, Some(8), "v= came from the opener"); | |
| 802 | + | assert!(control.no_cursor_move, "C= came from the opener"); | |
| 803 | + | } | |
| 804 | + | ||
| 561 | 805 | // ---- control-field parsing --------------------------------------- | |
| 562 | 806 | ||
| 563 | 807 | #[test] |
| @@ -60,13 +60,17 @@ | |||
| 60 | 60 | ||
| 61 | 61 | /// Panics if `control`'s query answer is not one a client can use. | |
| 62 | 62 | /// | |
| 63 | + | /// Returns whether the answer claimed OK, for the same reason [`check_body`] | |
| 64 | + | /// returns a bool: a body of pure assertions replaced by `()` is invisible to | |
| 65 | + | /// every test that passes, so the oracle has to hand back something. | |
| 66 | + | /// | |
| 63 | 67 | /// # Panics | |
| 64 | 68 | /// | |
| 65 | 69 | /// By design. It is an oracle, and a panic is how it reports. | |
| 66 | 70 | // The reply is tens of bytes long, so the bytecount crate would be a | |
| 67 | 71 | // dependency bought for nothing. | |
| 68 | 72 | #[allow(clippy::naive_bytecount)] | |
| 69 | - | pub fn check_query_response(control: &Control) { | |
| 73 | + | pub fn check_query_response(control: &Control) -> bool { | |
| 70 | 74 | let reply = query_response(control); | |
| 71 | 75 | assert!( | |
| 72 | 76 | reply.starts_with(b"\x1b_G"), | |
| @@ -100,30 +104,53 @@ | |||
| 100 | 104 | "query answered {text:?} for format {:?} medium {:?}", | |
| 101 | 105 | control.format, control.medium | |
| 102 | 106 | ); | |
| 107 | + | claims_ok | |
| 103 | 108 | } | |
| 104 | 109 | ||
| 105 | 110 | /// Panics if the same payload sent as chunks does not arrive as the same bytes. | |
| 106 | 111 | /// | |
| 112 | + | /// Returns how many chunks it took, for the same reason as the rest of this | |
| 113 | + | /// module: an oracle that returns nothing cannot be observed to have run. | |
| 114 | + | /// | |
| 107 | 115 | /// # Panics | |
| 108 | 116 | /// | |
| 109 | 117 | /// By design. | |
| 110 | - | fn check_chunk_equivalence(action: char, payload: &[u8]) { | |
| 118 | + | /// How wide a chunk to cut the encoded payload into: roughly a third of it, | |
| 119 | + | /// rounded to a whole number of base64 quanta. | |
| 120 | + | /// | |
| 121 | + | /// Chunk boundaries are multiples of four, which is what the protocol requires | |
| 122 | + | /// of a sender: each chunk is decoded on its own, so a boundary inside a quantum | |
| 123 | + | /// would not be decodable by any implementation. | |
| 124 | + | /// | |
| 125 | + | /// The floor of one quantum is load-bearing rather than defensive. The walk | |
| 126 | + | /// below consumes `chunk_len` bytes per turn, so a width of zero does not | |
| 127 | + | /// terminate, and the arithmetic here is the only thing that could produce one. | |
| 128 | + | /// Named and floored, it is a thing a test can state instead of a mutant that | |
| 129 | + | /// spends five minutes not answering. | |
| 130 | + | fn chunk_len_for(encoded_len: usize) -> usize { | |
| 131 | + | let quanta = encoded_len.div_ceil(4); | |
| 132 | + | (4 * quanta.div_ceil(3)).max(4) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn check_chunk_equivalence(action: char, payload: &[u8]) -> usize { | |
| 111 | 136 | if payload.is_empty() { | |
| 112 | - | return; | |
| 137 | + | return 0; | |
| 113 | 138 | } | |
| 114 | 139 | let encoded = B64.encode(payload); | |
| 115 | - | // Chunk boundaries are multiples of four, which is what the protocol | |
| 116 | - | // requires of a sender: each chunk is decoded on its own, so a boundary | |
| 117 | - | // inside a quantum would not be decodable by any implementation. | |
| 118 | - | let quanta = encoded.len().div_ceil(4); | |
| 119 | - | let chunk_len = 4 * quanta.div_ceil(3).max(1); | |
| 140 | + | let chunk_len = chunk_len_for(encoded.len()); | |
| 120 | 141 | ||
| 121 | 142 | let mut p = Parser::new(); | |
| 122 | 143 | let mut first = true; | |
| 123 | 144 | let mut rest = encoded.as_str(); | |
| 124 | 145 | let mut got = None; | |
| 146 | + | let mut chunks = 0; | |
| 125 | 147 | while !rest.is_empty() { | |
| 126 | - | let take = chunk_len.min(rest.len()); | |
| 148 | + | chunks += 1; | |
| 149 | + | // At least one byte, whatever the width says. The walk's termination | |
| 150 | + | // is then a property of the loop rather than of the arithmetic above | |
| 151 | + | // it, so a width that comes back wrong is a failed assertion below | |
| 152 | + | // instead of a run that never ends. | |
| 153 | + | let take = chunk_len.clamp(1, rest.len()); | |
| 127 | 154 | let (head, tail) = rest.split_at(take); | |
| 128 | 155 | rest = tail; | |
| 129 | 156 | let more = if rest.is_empty() { "0" } else { "1" }; | |
| @@ -162,6 +189,7 @@ | |||
| 162 | 189 | 0, | |
| 163 | 190 | "a completed transmission was left in flight" | |
| 164 | 191 | ); | |
| 192 | + | chunks | |
| 165 | 193 | } | |
| 166 | 194 | ||
| 167 | 195 | /// Feed one APC body to a fresh parser and hold what comes out to everything | |
| @@ -175,6 +203,25 @@ | |||
| 175 | 203 | /// # Panics | |
| 176 | 204 | /// | |
| 177 | 205 | /// By design, on any violation. | |
| 206 | + | /// The base64 characters in `body`: everything after the first `;`. | |
| 207 | + | /// | |
| 208 | + | /// Named because the bound it feeds is an upper one, and every mutation of this | |
| 209 | + | /// arithmetic makes it looser. A looser upper bound is still satisfied by a | |
| 210 | + | /// correct decode, so nothing in a passing run disagrees with a mutant here. | |
| 211 | + | fn b64_len_of(body: &[u8]) -> usize { | |
| 212 | + | body.iter() | |
| 213 | + | .position(|&b| b == b';') | |
| 214 | + | .map_or(0, |i| body.len() - i - 1) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// The most a parser may retain after `input_len` bytes of input. | |
| 218 | + | /// | |
| 219 | + | /// Same reason as [`b64_len_of`]: it is a ceiling, so every mutation raises it | |
| 220 | + | /// and a well-behaved parser stays under both. | |
| 221 | + | fn retained_ceiling(input_len: usize) -> usize { | |
| 222 | + | RETAINED_BASE_BYTES + input_len.saturating_mul(MAX_RETAINED_PER_INPUT_BYTE) | |
| 223 | + | } | |
| 224 | + | ||
| 178 | 225 | pub fn check_body(body: &[u8]) -> bool { | |
| 179 | 226 | let mut p = Parser::new(); | |
| 180 | 227 | let Some(cmd) = p.feed(body) else { | |
| @@ -189,15 +236,14 @@ | |||
| 189 | 236 | ); | |
| 190 | 237 | ||
| 191 | 238 | match &cmd { | |
| 192 | - | Command::Query { control } => check_query_response(control), | |
| 239 | + | Command::Query { control } => { | |
| 240 | + | check_query_response(control); | |
| 241 | + | } | |
| 193 | 242 | Command::Transmit { control, payload } | Command::FrameAppend { control, payload } => { | |
| 194 | 243 | // base64 carries three bytes in every four characters, so a decoded | |
| 195 | 244 | // payload longer than that is the accumulator's own arithmetic | |
| 196 | 245 | // disagreeing with itself. | |
| 197 | - | let b64_len = body | |
| 198 | - | .iter() | |
| 199 | - | .position(|&b| b == b';') | |
| 200 | - | .map_or(0, |i| body.len() - i - 1); | |
| 246 | + | let b64_len = b64_len_of(body); | |
| 201 | 247 | assert!( | |
| 202 | 248 | payload.len() <= b64_len.div_ceil(4) * 3, | |
| 203 | 249 | "decoded {} bytes out of {b64_len} base64 characters", | |
| @@ -245,10 +291,19 @@ | |||
| 245 | 291 | // Every body is also checked on its own, where a completed command can | |
| 246 | 292 | // be held to the properties that need one. | |
| 247 | 293 | check_body(piece); | |
| 294 | + | // Per body, not only at the end. The cap is what keeps the map from | |
| 295 | + | // growing with the id space, and a parser that has stopped enforcing | |
| 296 | + | // it gets slower with every body it takes: checked once at the end, | |
| 297 | + | // the oracle spends the whole input finding that out. | |
| 298 | + | assert!( | |
| 299 | + | session.pending_transmissions() <= crate::MAX_IN_FLIGHT_TRANSMISSIONS, | |
| 300 | + | "{} transmissions in flight after {fed} bodies, over the cap", | |
| 301 | + | session.pending_transmissions() | |
| 302 | + | ); | |
| 248 | 303 | } | |
| 249 | 304 | ||
| 250 | 305 | let retained = session.pending_bytes(); | |
| 251 | - | let ceiling = RETAINED_BASE_BYTES + input.len().saturating_mul(MAX_RETAINED_PER_INPUT_BYTE); | |
| 306 | + | let ceiling = retained_ceiling(input.len()); | |
| 252 | 307 | assert!( | |
| 253 | 308 | retained <= ceiling, | |
| 254 | 309 | "parser holds {retained} bytes after {} bytes of input, over the {ceiling} ceiling", | |
| @@ -265,3 +320,118 @@ | |||
| 265 | 320 | ); | |
| 266 | 321 | fed | |
| 267 | 322 | } | |
| 323 | + | ||
| 324 | + | #[cfg(test)] | |
| 325 | + | mod tests { | |
| 326 | + | //! Unit tests for the oracle's own decisions. | |
| 327 | + | //! | |
| 328 | + | //! The oracle asserts things about the crate; nothing asserted anything | |
| 329 | + | //! about the oracle. Every function below is either a ceiling the crate is | |
| 330 | + | //! held to, or a piece of arithmetic that decides how much of the input the | |
| 331 | + | //! oracle looks at. Neither can be observed through a passing run: a looser | |
| 332 | + | //! ceiling is still satisfied by a correct parser, and a narrower walk still | |
| 333 | + | //! agrees with everything it did check. | |
| 334 | + | ||
| 335 | + | use super::{ | |
| 336 | + | MAX_RETAINED_BYTES, RETAINED_BASE_BYTES, b64_len_of, check_body, check_chunk_equivalence, | |
| 337 | + | check_query_response, chunk_len_for, retained_ceiling, | |
| 338 | + | }; | |
| 339 | + | use crate::{Control, Format, Medium}; | |
| 340 | + | use base64::{Engine, engine::general_purpose::STANDARD as B64}; | |
| 341 | + | ||
| 342 | + | #[test] | |
| 343 | + | fn the_absolute_retention_ceiling_is_two_budgets_and_a_slack() { | |
| 344 | + | // Written out, so a mutant rewriting the expression in the source has | |
| 345 | + | // nothing here to agree with. | |
| 346 | + | assert_eq!(MAX_RETAINED_BYTES, 134_283_264); | |
| 347 | + | assert_eq!(RETAINED_BASE_BYTES, 4096); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn the_per_input_ceiling_is_the_base_plus_four_per_byte() { | |
| 352 | + | assert_eq!(retained_ceiling(1000), 8096); | |
| 353 | + | assert_eq!(retained_ceiling(0), RETAINED_BASE_BYTES); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn the_chunk_width_is_a_third_of_the_payload_in_whole_quanta() { | |
| 358 | + | // 128 encoded characters is 32 quanta; a third of that, rounded up, is | |
| 359 | + | // 11 quanta of four bytes each. | |
| 360 | + | assert_eq!(chunk_len_for(128), 44); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn the_chunk_width_never_falls_below_one_quantum() { | |
| 365 | + | // The walk consumes chunk_len bytes a turn, so a zero here does not | |
| 366 | + | // terminate. This is the floor that makes that unrepresentable. | |
| 367 | + | assert_eq!(chunk_len_for(0), 4); | |
| 368 | + | assert_eq!(chunk_len_for(4), 4); | |
| 369 | + | assert!(chunk_len_for(3) >= 4); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn the_base64_length_is_what_follows_the_first_semicolon() { | |
| 374 | + | assert_eq!(b64_len_of(b"a=T,f=32;QUJD"), 4); | |
| 375 | + | assert_eq!( | |
| 376 | + | b64_len_of(b"a=T,f=32"), | |
| 377 | + | 0, | |
| 378 | + | "a body with no payload marker carries no base64" | |
| 379 | + | ); | |
| 380 | + | assert_eq!( | |
| 381 | + | b64_len_of(b"a=T;QUJD;RUZH"), | |
| 382 | + | 9, | |
| 383 | + | "the FIRST semicolon is the separator; later ones are payload" | |
| 384 | + | ); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn chunk_equivalence_reports_how_many_chunks_it_took() { | |
| 389 | + | // 96 bytes encode to 128 characters, which is three chunks of 44. | |
| 390 | + | assert_eq!(check_chunk_equivalence('T', &[0x5A; 96]), 3); | |
| 391 | + | assert_eq!( | |
| 392 | + | check_chunk_equivalence('T', &[]), | |
| 393 | + | 0, | |
| 394 | + | "an empty payload is not chunked at all" | |
| 395 | + | ); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | #[test] | |
| 399 | + | fn check_body_reports_whether_a_command_came_out() { | |
| 400 | + | let body = format!("Ga=T,f=32,s=2,v=2;{}", B64.encode([0x11u8; 16])); | |
| 401 | + | assert!(check_body(body.as_bytes()), "a valid transmit yields one"); | |
| 402 | + | assert!( | |
| 403 | + | !check_body(b"Gnot-a-command"), | |
| 404 | + | "a body that parses to nothing yields none" | |
| 405 | + | ); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn the_query_answer_says_ok_only_for_a_medium_the_host_can_serve() { | |
| 410 | + | let direct = Control { | |
| 411 | + | action: 'q', | |
| 412 | + | format: Some(Format::Rgba), | |
| 413 | + | medium: Some(Medium::Direct), | |
| 414 | + | id: Some(4242), | |
| 415 | + | ..Control::default() | |
| 416 | + | }; | |
| 417 | + | assert!(check_query_response(&direct), "inline base64 is servable"); | |
| 418 | + | ||
| 419 | + | let from_file = Control { | |
| 420 | + | medium: Some(Medium::File), | |
| 421 | + | ..direct.clone() | |
| 422 | + | }; | |
| 423 | + | assert!( | |
| 424 | + | !check_query_response(&from_file), | |
| 425 | + | "a file transfer must not be answered OK" | |
| 426 | + | ); | |
| 427 | + | ||
| 428 | + | let no_format = Control { | |
| 429 | + | format: None, | |
| 430 | + | ..direct | |
| 431 | + | }; | |
| 432 | + | assert!( | |
| 433 | + | !check_query_response(&no_format), | |
| 434 | + | "a query naming no format is not something to say OK to" | |
| 435 | + | ); | |
| 436 | + | } | |
| 437 | + | } |