Skip to content

Commit 143b07b

Browse files
MagicalTuxclaude
andcommitted
qr: read compressed BBQr, expanding it through the paced driver
`Z` deflates a file and only then cuts it into parts, so a scan of one collects the compressed stream and has to expand it. On the Q1 image that is 342 codes instead of 449 -- a quarter fewer, not the half the compression ratio suggests, because the compressor keeps its back-references inside a kilobyte so a device can expand the result with a window it can afford. **Nothing holds a slice over the mapped region.** `inflate_to_slice` would have been four lines and is the wrong tool: inflate writes a byte at a time and reads its own history back, and byte stores and unpaced reads are the two things PSRAM mis-issues. So the window lives in ordinary memory and both ends cross the medium in whole chunks through `StagingArea` -- `minizlib::Reader` fetching input a chunk at a time, `minizlib::Stream` handing output back a window at a time. Between them the decompressor never touches the bus. Reading the stream and writing the expansion are the same area and the two callbacks cannot each hold `&mut`. They run strictly in turn, so a `RefCell` says so honestly -- and the borrow is *tried*, because a panic partway through writing a firmware image is the worst place here to be wrong about a lifetime. The stream lands at 6 MiB and expands down from zero, so the expansion never overwrites input it has not read. A test pins that rather than the comment being the only thing that says it. It lives in `catcard-upgrade::expand` rather than the firmware so it can be tested against a memory-backed area: six tests, including that the chunk size changes only how often the bus turns round, that too wide a sender window is named rather than guessed, and that **damage survives to where the signature can see it** -- raw deflate has no checksum, so a corrupt stream can expand to exactly the right length and simply be the wrong bytes. That is not a gap to close here; the image signature is the check. What would be wrong is expanding the damage away. `catcard-image qr` compresses when that makes fewer codes and says which it used. The compressor can make a file with little redundancy longer, so the shorter of the two wins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 27b2b1e commit 143b07b

13 files changed

Lines changed: 548 additions & 49 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/catcard-bbqr/src/lib.rs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
//! counted on sight would report a complete file after a write that failed, and for a
2323
//! firmware image the only remaining check would be the signature.
2424
//!
25+
//! # `Z` reassembles a stream, not a file
26+
//!
27+
//! [`Encoding::Zlib`] deflates the whole file and only then cuts it up, so the parts
28+
//! place and reassemble exactly as any others do -- what comes out is the compressed
29+
//! stream. [`Collector::compressed`] says so; expanding it is the caller's, because
30+
//! only the caller knows where the bytes landed and what it can spend.
31+
//!
2532
//! # Where the bytes go is the caller's business
2633
//!
2734
//! Two consumers want different destinations: a firmware image goes to the staging area,
@@ -41,14 +48,6 @@ pub use outscript::bbqr::{
4148
pub enum Error {
4249
/// The format itself refused it: not a header, bad base32, an index out of range.
4350
Codec(outscript::bbqr::Error),
44-
/// The parts are compressed as a whole, which this cannot reassemble in place.
45-
///
46-
/// `Z` deflates the file *before* cutting it up, so no part means anything on its
47-
/// own and the entire compressed stream has to be whole before any of it is data.
48-
/// For a firmware image the only memory that holds it is the staging area the image
49-
/// is being written into, so inflating would read that part while writing to it --
50-
/// the documented way to corrupt it (`hw-reference/storage.md`).
51-
Compressed,
5251
/// This part disagrees with the ones already seen about what file this is.
5352
Mismatch,
5453
/// The payload decodes to more than the caller left room for.
@@ -119,6 +118,15 @@ impl Collector {
119118
self.header
120119
}
121120

121+
/// Whether what is being collected is a deflate stream rather than the file.
122+
///
123+
/// `Z` compresses the whole file before cutting it up, so the parts reassemble into
124+
/// something that still has to be expanded. Nothing here does that -- the caller
125+
/// knows where the bytes went and what it can spend on expanding them.
126+
pub fn compressed(&self) -> bool {
127+
self.header.is_some_and(|h| h.encoding == Encoding::Zlib)
128+
}
129+
122130
/// Whether every part has been seen.
123131
pub fn complete(&self) -> bool {
124132
self.header.is_some_and(|h| self.count == h.num_parts)
@@ -135,9 +143,6 @@ impl Collector {
135143
/// written it.
136144
pub fn accept(&mut self, line: &str) -> Result<Placed, Error> {
137145
let (header, body) = Header::parse(line)?;
138-
if header.encoding == Encoding::Zlib {
139-
return Err(Error::Compressed);
140-
}
141146
let len = decoded_len_bound(header.encoding, body.len());
142147
let is_last = header.index + 1 == header.num_parts;
143148

crates/catcard-bbqr/src/tests.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,14 +263,28 @@ fn hex_works_too() {
263263
assert_eq!(out, file);
264264
}
265265

266-
/// `Z` deflates the whole file before cutting it, so no part is data on its own and the
267-
/// compressed stream has to be reassembled before any of it can be inflated. Refused
268-
/// with its own reason, so the screen can say why rather than "bad code".
266+
/// `Z` deflates the whole file before cutting it, so the parts place like any others
267+
/// and what they reassemble into is the compressed stream. The collector says so and
268+
/// leaves the expanding to whoever knows where the bytes went.
269269
#[test]
270-
fn compressed_parts_are_refused_by_name() {
270+
fn compressed_parts_place_like_any_other() {
271+
let stream = firmwareish(200);
272+
let mut out = vec![0u8; stream.len()];
271273
let mut c = Collector::new();
272-
let line = part_with(Encoding::Zlib, &[1, 2, 3, 4, 5], 2, 0);
273-
assert_eq!(c.accept(&line), Err(Error::Compressed));
274+
assert!(!c.compressed(), "nothing seen yet");
275+
for i in 0..2u16 {
276+
let at = i as usize * 100;
277+
c.take(
278+
&part_with(Encoding::Zlib, &stream[at..at + 100], 2, i),
279+
&mut out,
280+
)
281+
.expect("a part");
282+
}
283+
assert!(c.compressed());
284+
assert!(c.complete());
285+
// The reassembled bytes are the deflate stream, not the file.
286+
assert_eq!(c.file_len(), Some(stream.len()));
287+
assert_eq!(out, stream);
274288
}
275289

276290
#[test]

crates/catcard-fw/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ catcard-log.workspace = true
6262
catcard-wallet = { workspace = true, default-features = false }
6363
catcard-ui.workspace = true
6464
anyd.workspace = true
65+
minizlib.workspace = true
6566
cortex-m.workspace = true
6667
heapless.workspace = true
6768
cortex-m-rt.workspace = true

crates/catcard-fw/src/inflate.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//! The heap buffers a paced inflate needs, and where a compressed scan lands.
2+
//!
3+
//! The expansion itself is [`catcard_upgrade::expand`], which is where it can be tested
4+
//! against a memory-backed area rather than only against the real part. This is the
5+
//! wrapper that knows what this device can spend on it.
6+
//!
7+
//! BBQr's `Z` compresses a file and only then cuts it into parts, so a scan of one
8+
//! collects the compressed stream. On the Q1 image that is 342 codes instead of 449 --
9+
//! a quarter fewer, not the half the ratio suggests, because the compressor keeps its
10+
//! back-references inside a kilobyte so that a device can expand the result with a
11+
//! window it can afford.
12+
13+
use crate::staging::Area;
14+
15+
/// The history window given to the decompressor, from the heap.
16+
///
17+
/// BBQr compresses within a kilobyte, so this is eight times what a conforming sender
18+
/// needs. A stream made with a wider one fails with a reason rather than quietly
19+
/// producing wrong bytes -- a back-reference past the window is exactly the case the
20+
/// decompressor cannot serve, and it says so.
21+
pub const WINDOW: usize = 8 * 1024;
22+
23+
/// Compressed input fetched per bus turnaround.
24+
///
25+
/// Every chunk costs a direction change and the driver pays a CE# gap for each, so a
26+
/// kilobyte at a time makes those rare without asking much of the heap.
27+
const CHUNK: usize = 1024;
28+
29+
/// Where the scanner puts a compressed stream, as an offset into the staging area.
30+
///
31+
/// Past anything that can be expanded out of it: the expanded image starts at zero and
32+
/// the bootloader will not take one larger than the board's flash, which is a fraction
33+
/// of this. Keeping the two apart is what lets the expansion run forwards without ever
34+
/// overwriting input it has not read yet.
35+
pub const COMPRESSED_AT: u32 = 6 * 1024 * 1024;
36+
37+
/// Expand `len` compressed bytes at [`COMPRESSED_AT`] into the area from offset zero.
38+
///
39+
/// The buffers come from the heap, which is the only reason this is not just the call
40+
/// underneath. Returns how many bytes came out.
41+
pub fn staged(area: &mut Area, len: u32, max: u32) -> Result<u32, &'static str> {
42+
let (Some(mut window), Some(mut chunk)) = (crate::heap::take(WINDOW), crate::heap::take(CHUNK))
43+
else {
44+
return Err("not enough memory to expand it");
45+
};
46+
catcard_upgrade::expand::inflate(area, COMPRESSED_AT, len, max, window.bytes(), chunk.bytes())
47+
.map_err(|why| {
48+
crate::catlog!("inflate: refused: {:?}", why);
49+
match why {
50+
catcard_upgrade::expand::Error::WindowTooSmall => {
51+
"compressed with too wide a window"
52+
}
53+
catcard_upgrade::expand::Error::TooLong => "it expands to more than can be staged",
54+
catcard_upgrade::expand::Error::Storage => "staging write failed",
55+
catcard_upgrade::expand::Error::Damaged => "the compressed data is damaged",
56+
}
57+
})
58+
}

crates/catcard-fw/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ mod keywork;
4444
mod logbuf;
4545
#[cfg(feature = "usb-debug-mem")]
4646
mod debug_mem;
47+
/// Expanding a deflate stream that is already in the staging area.
48+
#[cfg(feature = "board-q1")]
49+
mod inflate;
4750
mod interrupts;
4851
mod ktest;
4952
mod menu;

crates/catcard-fw/src/qrload.rs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ pub(crate) trait Sink {
4040

4141
/// Put `bytes` at `offset`. An error abandons the transfer.
4242
fn place(&mut self, offset: usize, bytes: &[u8]) -> Result<(), &'static str>;
43+
44+
/// What is arriving is a deflate stream, not the payload.
45+
///
46+
/// Said once, before any bytes, because where a sink puts a stream it will have to
47+
/// expand is not where it puts a file it can use as it stands.
48+
fn compressed(&mut self) -> Result<(), &'static str> {
49+
Ok(())
50+
}
51+
}
52+
53+
/// What a completed scan left behind.
54+
pub(crate) struct Received {
55+
/// Bytes handed to the sink.
56+
pub len: usize,
57+
/// Whether they are a deflate stream rather than the payload itself.
58+
pub compressed: bool,
4359
}
4460

4561
/// What one code turned out to be worth.
@@ -49,6 +65,8 @@ struct Landed {
4965
total: u32,
5066
/// The payload's length, once every part is in. `None` while any are missing.
5167
complete: Option<usize>,
68+
/// Whether what is being placed is a deflate stream.
69+
compressed: bool,
5270
}
5371

5472
/// Which format is being read, decided by the first line that parsed.
@@ -66,7 +84,7 @@ enum Which {
6684
///
6785
/// `None` if the owner cancelled or the scanner could not be used; the reason has
6886
/// already been shown in either case.
69-
pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> Option<usize> {
87+
pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> Option<Received> {
7088
// Big enough for the largest line either format can hand over, decoded. BBQr's
7189
// base32 is five bits a character, so a line's payload is never more than five
7290
// eighths of it; BC-UR's bytewords are two characters a byte, so never more than a
@@ -79,12 +97,15 @@ pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> O
7997
};
8098

8199
let mut which = Which::Unknown;
100+
// The sink is told once that a stream is coming, not once per part.
101+
let mut told = false;
82102
let mut failure: Option<&'static str> = None;
83103
// Redrawn only when a part lands that was not already held. Every other code is a
84104
// repeat of one already caught, and redrawing for those would make the screen flicker
85105
// through the whole animation without the count ever moving.
86106
let mut shown = (0u32, 0u32);
87107
let mut done = 0usize;
108+
let mut compressed = false;
88109

89110
let outcome = qrscan::scan_many(ui, head, &mut |ui, line| {
90111
let scratch = scratch_mem.bytes();
@@ -105,8 +126,9 @@ pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> O
105126
let Some(text) = as_text(line) else {
106127
return Next::More;
107128
};
108-
match read_one(&mut which, text, scratch, sink) {
129+
match read_one(&mut which, &mut told, text, scratch, sink) {
109130
Ok(Some(landed)) => {
131+
compressed = landed.compressed;
110132
if (landed.have, landed.total) != shown {
111133
shown = (landed.have, landed.total);
112134
progress(ui, head, landed.have, landed.total);
@@ -136,7 +158,10 @@ pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> O
136158
return None;
137159
}
138160
match outcome {
139-
Ok(()) if done > 0 => Some(done),
161+
Ok(()) if done > 0 => Some(Received {
162+
len: done,
163+
compressed,
164+
}),
140165
Ok(()) => None,
141166
Err(qrscan::Fault::Cancelled) => None,
142167
Err(why) => {
@@ -153,6 +178,7 @@ pub(crate) fn collect_any(ui: &mut Ui<'_>, head: &str, sink: &mut dyn Sink) -> O
153178
/// fatal: the sink refused, or two different files are in shot.
154179
fn read_one(
155180
which: &mut Which,
181+
told: &mut bool,
156182
line: &str,
157183
scratch: &mut [u8],
158184
sink: &mut dyn Sink,
@@ -173,17 +199,18 @@ fn read_one(
173199
match which {
174200
Which::Unknown => Ok(None),
175201
Which::Bbqr(collector) => {
176-
let placed = match collector.accept(line) {
177-
Ok(placed) => placed,
178-
// The one refusal worth a screen: the sender compressed the file, which
179-
// cannot be reassembled in place. Everything else is a bad frame.
180-
Err(catcard_bbqr::Error::Compressed) => return Err("send it uncompressed"),
181-
Err(_) => return Ok(None),
202+
let Ok(placed) = collector.accept(line) else {
203+
return Ok(None);
182204
};
183205
// An upper bound: every part but the last is this long, and the last is
184206
// shorter. Told before anything is written, because a sink that sizes itself
185207
// from this cannot be told after the fact.
186208
sink.expect(placed.len * placed.total as usize)?;
209+
// Before any bytes land, because it decides where they land.
210+
if collector.compressed() && !*told {
211+
sink.compressed()?;
212+
*told = true;
213+
}
187214
if placed.fresh {
188215
let room = scratch.get_mut(..placed.len).ok_or("a part was too long")?;
189216
if catcard_bbqr::decode_part_to_slice(line, room).is_err() {
@@ -202,6 +229,7 @@ fn read_one(
202229
have: placed.have as u32,
203230
total: placed.total as u32,
204231
complete: len,
232+
compressed: collector.compressed(),
205233
}))
206234
}
207235
Which::Bcur(collector) => {
@@ -235,6 +263,8 @@ fn read_one(
235263
have,
236264
total,
237265
complete: collector.complete().then_some(total_len).flatten(),
266+
// BC-UR has no compressed form; a UR carries what it carries.
267+
compressed: false,
238268
}))
239269
}
240270
}
@@ -297,11 +327,15 @@ fn progress(ui: &mut Ui<'_>, head: &str, have: u32, total: u32) {
297327
/// satisfied it.
298328
pub(crate) struct Staging {
299329
area: crate::staging::Area,
330+
/// Where offset zero of the payload goes. Zero for a file, and out of the way for a
331+
/// deflate stream, which has to survive being read while what it expands to is
332+
/// written over the front of the area.
333+
base: u32,
300334
}
301335

302336
impl Staging {
303337
pub fn new(area: crate::staging::Area) -> Self {
304-
Staging { area }
338+
Staging { area, base: 0 }
305339
}
306340

307341
/// The area back, with whatever was written in it.
@@ -314,18 +348,25 @@ impl Sink for Staging {
314348
fn expect(&mut self, about: usize) -> Result<(), &'static str> {
315349
use catcard_upgrade::StagingArea as _;
316350

317-
if about as u32 > self.area.capacity() {
351+
let end = self.base.checked_add(about as u32).ok_or("bad length")?;
352+
if end > self.area.capacity() {
318353
return Err("too large for this device");
319354
}
320355
Ok(())
321356
}
322357

358+
fn compressed(&mut self) -> Result<(), &'static str> {
359+
self.base = crate::inflate::COMPRESSED_AT;
360+
Ok(())
361+
}
362+
323363
fn place(&mut self, offset: usize, bytes: &[u8]) -> Result<(), &'static str> {
324364
use catcard_upgrade::StagingArea as _;
325365

326366
let offset: u32 = offset.try_into().map_err(|_| "bad offset")?;
367+
let at = self.base.checked_add(offset).ok_or("bad offset")?;
327368
self.area
328-
.write(offset, bytes)
369+
.write(at, bytes)
329370
.map_err(|_| "staging write failed")
330371
}
331372
}

crates/catcard-fw/src/qrscan.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,34 @@ pub(crate) fn screen(gate: &Callgate, login: &mut catcard_pin::Login, ui: &mut U
498498
// Cancelled, or a reason already shown.
499499
return;
500500
};
501-
crate::catlog!("qr: received {} bytes", got);
502-
offer(gate, login, ui, HEAD, sink.into_area(), got);
501+
let mut area = sink.into_area();
502+
crate::catlog!(
503+
"qr: received {} bytes{}",
504+
got.len,
505+
if got.compressed { ", compressed" } else { "" }
506+
);
507+
508+
// A `Z` transfer reassembles the deflate stream, not the file. Expanding it is a
509+
// pass over the whole thing, so it says so -- on a payload this size it is not
510+
// instant, and a screen that has stopped changing reads as a device that has hung.
511+
let len = if got.compressed {
512+
menu::blocking_screen(ui.panel, HEAD, "expanding");
513+
let max = catcard_board::BOARD.memory.firmware_flash_len;
514+
match crate::inflate::staged(&mut area, got.len as u32, max) {
515+
Ok(n) => {
516+
crate::catlog!("qr: expanded to {} bytes", n);
517+
n as usize
518+
}
519+
Err(why) => {
520+
menu::message(ui.panel, HEAD, why, "any key to go back");
521+
menu::wait_for_any_key(ui);
522+
return;
523+
}
524+
}
525+
} else {
526+
got.len
527+
};
528+
offer(gate, login, ui, HEAD, area, len);
503529
}
504530

505531
/// What the scanned bytes look like.

0 commit comments

Comments
 (0)